<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: vadim albarov</title>
    <description>The latest articles on DEV Community by vadim albarov (@vadim_albarov).</description>
    <link>https://dev.to/vadim_albarov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4058639%2Fae7afcec-637e-4f73-bea2-21b8cd0d9c4f.jpg</url>
      <title>DEV Community: vadim albarov</title>
      <link>https://dev.to/vadim_albarov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vadim_albarov"/>
    <language>en</language>
    <item>
      <title>Windows Copilot Inverted a HIPAA Rule Three Times - and Cited a Real Paper That Proves the Opposite</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Thu, 20 Aug 2026 03:49:01 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/windows-copilot-inverted-a-hipaa-rule-three-times-and-cited-a-real-paper-that-proves-the-opposite-11ef</link>
      <guid>https://dev.to/vadim_albarov/windows-copilot-inverted-a-hipaa-rule-three-times-and-cited-a-real-paper-that-proves-the-opposite-11ef</guid>
      <description>&lt;p&gt;I have a two-window habit. My main work happens in a Claude Code session, and I don't like burning its context on side questions - so for quick lookups I alt-tab into Windows Copilot and use it as a search-flavored notepad. Ask, skim, close, back to work. It's been part of my routine for a long time, and honestly, I'd never had a problem with its answers before - side questions came back reasonable, and nothing ever sent me down a wrong path. Low stakes, or so I thought.&lt;/p&gt;

&lt;p&gt;This week the side window taught me a HIPAA rule with total confidence. The rule was backwards. Not vague, not incomplete - inverted, in the one direction that would turn a compliance question into a breach.&lt;/p&gt;

&lt;p&gt;I build healthcare infrastructure, so I had the reflexes to check. This is the story of what it said, what the regulation actually says, how the wrong answer survived thinking mode, search mode, and a demand for references - and how the citation trail eventually explained where the inversion came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;I was thinking about deterministic pseudonymization: replacing a patient identifier with &lt;code&gt;hash(key + identifier)&lt;/code&gt; so the same patient always maps to the same token and records stay joinable. The obvious follow-up is what HIPAA thinks about the key. Keep it secret? Share it? Does it matter? Full disclosure: the question I actually typed was leading - I'd already run into the claim that sharing the key was the compliant option somewhere, and I asked about it as if it were established.&lt;/p&gt;

&lt;p&gt;Copilot's answer, summarized:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you keep a &lt;strong&gt;secret key&lt;/strong&gt;, you retain the ability to re-identify. Therefore the data is pseudonymized, still PHI, not de-identified.&lt;/li&gt;
&lt;li&gt;If the key is &lt;strong&gt;public&lt;/strong&gt;, nobody has privileged knowledge. Nobody can reverse a one-way hash. Therefore the data can qualify as de-identified.&lt;/li&gt;
&lt;li&gt;HIPAA, it explained, cares about &lt;em&gt;privileged access&lt;/em&gt;, not cryptographic strength. "You're thinking like an engineer. HIPAA is written by lawyers."&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fllawb1acn7sz56s5fu3r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fllawb1acn7sz56s5fu3r.png" alt="pic 1" width="799" height="218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdx9j5ef854i1l3lq6r3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdx9j5ef854i1l3lq6r3.png" alt="pic 2" width="800" height="204"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's a seductive answer. It has a philosophy. It flatters you for being confused. It reads like someone explaining a genuinely counterintuitive corner of law.&lt;/p&gt;

&lt;p&gt;It is also wrong on both branches, and the second branch is dangerous.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the regulation actually says
&lt;/h2&gt;

&lt;p&gt;The relevant text is &lt;a href="https://www.law.cornell.edu/cfr/text/45/164.514" rel="noopener noreferrer"&gt;45 CFR 164.514&lt;/a&gt;. Safe Harbor's identifier list ends with a catch-all - "any other unique identifying number, characteristic, or code" - with exactly one exception: a re-identification code that satisfies paragraph (c). Paragraph (c) has two prongs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Derivation.&lt;/strong&gt; The code must &lt;em&gt;not&lt;/em&gt; be derived from or related to information about the individual, and must not be otherwise translatable back to the individual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; The covered entity must not use or disclose the code for other purposes and must not disclose &lt;em&gt;the mechanism for re-identification&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Read those against the two branches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secret-key hash.&lt;/strong&gt; &lt;code&gt;HMAC(secret, MRN)&lt;/code&gt; fails Safe Harbor - but not for Copilot's reason. It fails because the token is mathematically &lt;em&gt;derived&lt;/em&gt; from the identifier, and prong 1 prohibits derivation outright, regardless of how well you guard the key. Meanwhile, Copilot's actual claim - "if you can re-identify, it's not de-identified" - contradicts the regulation's text. Paragraph (c) exists precisely so a covered entity &lt;em&gt;can&lt;/em&gt; keep a secret re-identification mechanism while the dataset remains de-identified. Retained re-identification capability is not the disqualifier; derivation and disclosure are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public-key hash.&lt;/strong&gt; This one fails everything at once. Publishing the key is literally "disclosing the mechanism for re-identification" - prong 2, verbatim. And it makes the data trivially translatable - prong 1 - because health identifiers live in small, enumerable spaces. SSNs are a 10^9 space. Phone numbers, MRNs, emails, name-plus-birthdate: all enumerable. With the key public, anyone hashes every candidate and matches your entire column in seconds on a laptop. One-wayness protects high-entropy inputs; it does nothing for a nine-digit number.&lt;/p&gt;

&lt;p&gt;HHS's own &lt;a href="https://www.hhs.gov/hipaa/for-professionals/special-topics/de-identification/index.html" rel="noopener noreferrer"&gt;de-identification guidance&lt;/a&gt; closes the loop from both sides. It says a hash &lt;em&gt;without&lt;/em&gt; a secret key counts as an identifying element exactly because recipients can reverse it over the input space. And it says keyed cryptographic hashes are acceptable under the Expert Determination pathway &lt;em&gt;provided the keys are not disclosed, including to the recipients&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So the real rule is the mirror image of what my sidebar told me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Construction&lt;/th&gt;
&lt;th&gt;HIPAA status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hash with a &lt;strong&gt;published&lt;/strong&gt; key&lt;/td&gt;
&lt;td&gt;Fails everything - discloses the mechanism, trivially reversible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash with a &lt;strong&gt;secret&lt;/strong&gt; key&lt;/td&gt;
&lt;td&gt;Fails Safe Harbor (derived code); acceptable under Expert Determination with the key undisclosed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Random token&lt;/strong&gt; + protected mapping table&lt;/td&gt;
&lt;td&gt;The pattern 164.514(c) actually blesses - a random value is not "derived from" anyone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Round two: references make it worse
&lt;/h2&gt;

&lt;p&gt;Maybe I asked badly. I turned on thinking mode, then search mode, and asked it to rethink and support its answer with references.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ghnafr10ntf2wv1p5js.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ghnafr10ntf2wv1p5js.png" alt="pic 3" width="800" height="623"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I got the same inverted conclusion, now decorated with emoji section headers, a verdict table, and a source list - and with something worse. The centerpiece was a quoted sentence attributed to HHS, saying a code derived from PHI is an identifier "unless the re-identification key is not retained." I searched for that sentence. Not on HHS.gov, not anywhere I could find. The model composed a plausible-sounding rule that swaps the regulation's actual verb - &lt;em&gt;disclose&lt;/em&gt; - for &lt;em&gt;retain&lt;/em&gt;, wrapped it in quotation marks, and attached the genuine HHS URL to it.&lt;/p&gt;

&lt;p&gt;That's the failure mode that stuck with me: a real link laundering a fake quote. Every reader's citation heuristic - "it links to hhs.gov, so it's grounded" - defeated by construction.&lt;/p&gt;

&lt;p&gt;The funny part is that the correct answer was present in the same response, scattered in the margins. The risks section admitted that a public pepper can be brute-forced over common identifiers - which quietly destroys the headline claim that public-key hashing is irreversible. Another bullet correctly noted that keeping a secret key for linkage requires expert determination - which is the actual rule, and refutes the answer's own bottom line. The model had all the pieces and still shipped the inverted conclusion in the headline, the table, and the summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round three: it's not me
&lt;/h2&gt;

&lt;p&gt;One hypothesis left: maybe my framing poisoned the well, since my first question presented the counterintuitive claim as a finding. So I opened a fresh chat and asked the neutral, symmetric question - which is better for HIPAA, hash with a private key or a public key - with no premise embedded.&lt;/p&gt;

&lt;p&gt;Verdict, verbatim in spirit: public-key hashing is "far better," private-key hashing is still PHI, and - my favorite line - the public pepper should be "long, random, and not guessable."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mo6c9r2k935md90o70x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4mo6c9r2k935md90o70x.png" alt="pic 4" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;em&gt;public&lt;/em&gt; value that is &lt;em&gt;not guessable&lt;/em&gt;. It's published. That single phrase is the whole confusion in miniature: the answer needs the key to be simultaneously known to everyone (so nobody has privileged access) and known to no one (so nobody can brute-force). For calibration, I asked ChatGPT's web version the same neutral question. It got it essentially right: public-key hashing rejected for exactly the dictionary-attack reason, keyed HMAC labeled as pseudonymization that doesn't exit PHI obligations by itself, random tokens with a secured mapping table recommended for real de-identification. Not perfect - it never cited the derivation prohibition - but directionally sound everywhere it committed.&lt;/p&gt;

&lt;p&gt;Same question. One product inverted, reproducibly, across independent chats. The other didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The citation trail explains everything
&lt;/h2&gt;

&lt;p&gt;Both of the reference-backed Copilot answers - rounds two and three - leaned on the same academic source: a 2003 AMIA paper by Landi and Rao, &lt;a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC1479909/" rel="noopener noreferrer"&gt;"Secure De-identification and Re-identification"&lt;/a&gt;. I looked it up.&lt;/p&gt;

&lt;p&gt;The paper describes an &lt;em&gt;asymmetric encryption&lt;/em&gt; scheme: encrypt patient identifiers with a &lt;strong&gt;public key&lt;/strong&gt; so that only the holder of the matching &lt;strong&gt;private key&lt;/strong&gt; - the data owner - can decrypt and re-identify. "Public key" as in public-key cryptography. One half of a keypair. A system whose entire security rests on the &lt;em&gt;private&lt;/em&gt; key staying secret, and whose explicit purpose is to let the owner retain re-identification capability.&lt;/p&gt;

&lt;p&gt;Now the most plausible failure chain is visible. Retrieval surfaced a paper with "public key" and "de-identification" in close proximity. Summarization collapsed &lt;em&gt;public-key cryptography&lt;/em&gt; into &lt;em&gt;publicly known key&lt;/em&gt;. And then - this is the part I find genuinely instructive - the model didn't just misread a term. It constructed an entire regulatory philosophy around the misreading: the "privileged knowledge" theory of HIPAA, delivered with the confidence of a law professor, appearing in no regulation, contradicted by the very paper being cited. The citation that was supposed to ground the answer was proof of the opposite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which model was that, even?
&lt;/h2&gt;

&lt;p&gt;The chat window said "Smart." That's a mode label, not a model name. So I asked Copilot directly which model it runs on. It denied running on any outside lab's models at all - just "proprietary Windows AI technologies" - and said it cannot disclose specifics.&lt;/p&gt;

&lt;p&gt;That answer is worth exactly nothing, and knowing &lt;em&gt;why&lt;/em&gt; it's worth nothing is the useful part. A chatbot's claim about its own identity is generated text like everything else it says - models have no introspective access to the infrastructure serving them, and "I can't disclose" deflections are typically system-prompt policy, not knowledge. The same product that invented an HHS quote is not a reliable witness about its own internals.&lt;/p&gt;

&lt;p&gt;The public reporting says something different and messier: modern assistant products run mixed fleets - frontier models licensed from partner labs alongside the vendor's own in-house models - with a cost-driven router deciding invisibly, per query, which one you get. The lineup shifts between announcements, the routing shifts without any announcement at all, and there's no per-response indicator. The same question may be served by a different model next week, or later today.&lt;/p&gt;

&lt;p&gt;I don't know whether my three inverted answers came from a fast-path model, from the summarization layer garbling search results, or from the persona tuning that opens responses with "let me say this clearly and directly." That's the point: as a user, I &lt;em&gt;can't&lt;/em&gt; know - and asking the product just adds one more unverifiable claim to the pile. When another assistant answers the same question correctly, the difference isn't necessarily raw model capability. It's everything wrapped around the model, and the wrapper is invisible. "Which LLM does this product use" turns out to be the wrong question. The right one is "which pipeline, with which retrieval, which router, and which incentives" - and no consumer product answers it. Not even when you ask it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm taking away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confidence formatting is free.&lt;/strong&gt; Tables, verdict emoji, "let me say this clearly" - none of it correlates with correctness. The most wrong answer in this story was the best-formatted one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Support this with references" does not mean "verify this."&lt;/strong&gt; Once a model has committed to a conclusion, asking for sources can produce &lt;em&gt;justification&lt;/em&gt; instead - up to and including an invented quote pinned to a real government URL. If a quote matters, search for the exact sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for internal contradictions.&lt;/strong&gt; The wrong answers refuted themselves in their own risk sections. A response whose caveats disagree with its headline is telling you which part was retrieved and which part was composed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For anything regulatory, read the primary source.&lt;/strong&gt; 164.514(c) is two paragraphs. It cost five minutes and settled in one reading what three AI answers scrambled. Compliance-by-chatbot is how a "de-identified" dataset ships with a published key and becomes a reportable breach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The side window deserves the same skepticism as the main one.&lt;/strong&gt; My mistake wasn't using Copilot - it was granting the quick-lookup window a lower evidence bar because the questions felt small. Nothing about the window makes the answers smaller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm keeping the two-window setup, but the notepad seat is now vacant. Either something changed in that product - a router, a model swap, a summarization layer - or it could always do this and I simply never caught it; I have no way to tell, and that uncertainty is its own verdict. Either way, a tool I trusted for months just fabricated regulatory quotes with a straight face. So I'm dropping it until it stabilizes, and I'll know it has stabilized the same way I learned it broke: by spot-checking its answers against primary sources. A quick-lookup tool that requires verification of every answer isn't a quick-lookup tool anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Here is the prompt I used for the fresh-chat test, lightly tidied. Run it against your assistant of choice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In terms of HIPAA compliance, what is better for de-identifying PHI data: HASH(private_key + PHI) or HASH(public_key + PHI)?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The correct answer rejects both as de-identification on their own, flags the public-key variant as trivially reversible by dictionary attack, and mentions that a keyed hash only works under the Expert Determination pathway with the key kept undisclosed. Anything that tells you the public key is the compliant option has inverted 45 CFR 164.514(c).&lt;/p&gt;

&lt;p&gt;Share what you get in the comments - which product, which mode, and which way it landed. I'm genuinely curious whether this reproduces beyond my machine, and a comment thread of timestamped outputs is a better dataset.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclaimer: I'm an infrastructure engineer, not a lawyer; this is an engineering read of published regulations and guidance, not legal advice. The Copilot and ChatGPT responses summarized here were collected in August 2026 from consumer versions of both products; model routing and behavior change constantly, which is rather the point.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>hipaa</category>
      <category>security</category>
      <category>llm</category>
    </item>
    <item>
      <title>The De-identification Datasets Problem: i2b2/n2c2 Access Is Broken, and What to Do Instead</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Wed, 19 Aug 2026 03:27:41 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/the-de-identification-datasets-problem-i2b2n2c2-access-is-broken-and-what-to-do-instead-2mo7</link>
      <guid>https://dev.to/vadim_albarov/the-de-identification-datasets-problem-i2b2n2c2-access-is-broken-and-what-to-do-instead-2mo7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I'm building a PHI de-identification tool, and step one of doing that honestly is benchmarking it against the standard corpora: the i2b2 2006 and 2014 de-identification challenge datasets, now distributed as "n2c2" through Harvard DBMI's data portal. Step one failed. The portal's n2c2 page has said "Temporarily Unavailable" since at least 2026-07-28, registration is closed, and the old i2b2.org dataset page returns HTTP 500. Even when the door is open, access means per-user registration, a data use agreement, and an approval wait - which is why published de-id numbers are so hard to reproduce and why the field's canonical scores are 12-20 years old. I built a synthetic corpus generator instead. Here's the whole investigation, dated, plus what synthetic data honestly can and cannot replace.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step one: you can't get the data
&lt;/h2&gt;

&lt;p&gt;Benchmarking a de-identification tool sounds like the easy part of the project. The hard part is supposed to be the NLP: finding every patient name, date, medical record number, and phone number buried in messy clinical prose, so the notes can be used for research without exposing anyone. Detection is the science. Evaluation is just downloading the test set, right?&lt;/p&gt;

&lt;p&gt;Here is how evaluation actually went for me.&lt;/p&gt;

&lt;p&gt;Every de-identification paper of the last two decades benchmarks against the same corpora: the i2b2 de-identification challenge datasets from 2006 and 2014. If you want your numbers to mean anything to anyone, you report them on i2b2 2014. So on 2026-07-28 I went to get the data from the &lt;a href="https://portal.dbmi.hms.harvard.edu/" rel="noopener noreferrer"&gt;Harvard DBMI Data Portal&lt;/a&gt;, where the corpora live under their post-2018 name, n2c2 (National NLP Clinical Challenges). What I found was a notice: the n2c2 datasets are temporarily unavailable.&lt;/p&gt;

&lt;p&gt;Fine, I thought. Maintenance happens. I built other things and came back.&lt;/p&gt;

&lt;p&gt;As of today, 2026-08-18, nearly three weeks later, here is the exact state of the front door, checked from a fresh session:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The portal itself is up and loads fine.&lt;/li&gt;
&lt;li&gt;The portal's &lt;a href="https://portal.dbmi.hms.harvard.edu/data-sets/" rel="noopener noreferrer"&gt;data sets listing&lt;/a&gt; shows two entries. Neither is an n2c2 NLP corpus (both are 4CE COVID-related sets).&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://portal.dbmi.hms.harvard.edu/projects/n2c2-nlp/" rel="noopener noreferrer"&gt;n2c2 NLP Research Data Sets project page&lt;/a&gt; - the page for the actual challenge corpora, 2006 through 2018 - carries this banner, verbatim: "Temporarily Unavailable. The n2c2 datasets are temporarily unavailable. If you are trying to access data from the 2019 Challenge, tracks 1 (Clinical Semantic Textual Similarity) and 2 (Family History Extraction) are available directly through Mayo Clinic." Below the dataset descriptions: "Registration is not open ... at this time."&lt;/li&gt;
&lt;li&gt;The legacy home of these corpora, &lt;a href="https://www.i2b2.org/NLP/DataSets/" rel="noopener noreferrer"&gt;i2b2.org/NLP/DataSets&lt;/a&gt;, returns HTTP 500.&lt;/li&gt;
&lt;li&gt;The companion &lt;a href="https://n2c2.dbmi.hms.harvard.edu/" rel="noopener noreferrer"&gt;n2c2 informational site&lt;/a&gt; refused my scripted requests outright with an Akamai "Access Denied" page. That one I'll hedge: it may just be bot filtering rather than downtime. But it means I cannot even verify the documentation programmatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No ETA. No status page. No mirror. The canonical benchmark for an entire subfield of clinical NLP is a "temporarily unavailable" banner, and "temporarily" has meant at least July 28 through August 18 so far. It may come back tomorrow; the point of this article survives either way, because the outage is only the loudest symptom of a structural problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these datasets are, and why everyone needs them
&lt;/h2&gt;

&lt;p&gt;Some history, because the names are confusing. i2b2 (Informatics for Integrating Biology and the Bedside) was an NIH-funded center based at Partners HealthCare that, starting in 2006, ran annual clinical NLP shared tasks on real (de-identified) hospital notes. In 2018 the challenge series was renamed n2c2, and stewardship of the datasets moved to the Department of Biomedical Informatics at Harvard Medical School, distributed via their portal. Same corpora, three names, one door.&lt;/p&gt;

&lt;p&gt;Two of those challenges define de-identification evaluation to this day:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 2006 challenge&lt;/strong&gt; (&lt;a href="https://academic.oup.com/jamia/article/14/5/550/720189" rel="noopener noreferrer"&gt;Uzuner, Luo, and Szolovits, JAMIA 2007&lt;/a&gt;) used hospital discharge summaries in which the authentic PHI had been replaced with synthesized surrogates - including deliberately out-of-vocabulary, made-up names to punish systems that just memorized name lists. Seven teams, sixteen system runs, and the best systems scored above 98% F-measure across PHI categories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 2014 i2b2/UTHealth challenge&lt;/strong&gt; (&lt;a href="https://www.sciencedirect.com/science/article/pii/S1532046415001823" rel="noopener noreferrer"&gt;Stubbs and Uzuner, JBI 2015&lt;/a&gt;) raised the bar: 1,304 longitudinal records covering 296 patients, over 28,000 annotated PHI instances, annotated under a broad interpretation of HIPAA. Human annotators managed a token-level F1 of 0.927 against the gold standard; the &lt;a href="https://www.researchgate.net/publication/280584382_Automated_systems_for_the_de-identification_of_longitudinal_clinical_narratives_Overview_of_2014_i2b2UTHealth_shared_task_Track_1" rel="noopener noreferrer"&gt;best automated system&lt;/a&gt; hit a strict micro-averaged F1 of 0.936.&lt;/p&gt;

&lt;p&gt;Notice something about both: even the "real" gold standards contain synthetic PHI. The notes are genuine clinical text, but the identifiers in them are surrogates, inserted so the data could be released at all. Keep that in mind for later - the field's ground truth has always been real prose plus fake identifiers.&lt;/p&gt;

&lt;p&gt;That 0.936 from 2014 is, functionally, still the number. When you publish a de-id tool in 2026, reviewers ask how you compare on i2b2 2014. The corpus is twelve years old, drawn from one hospital system, and pre-dates most of what modern EHRs do to note formatting. It is also, at the moment, undownloadable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The access process, even on a good day
&lt;/h2&gt;

&lt;p&gt;Suppose the portal comes back tomorrow. What does access look like then? Per the archived instructions on i2b2.org, the datasets are "freely available" to researchers - subject to a data use agreement, and "each individual user must access the data independently" through the portal. In practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register an account on the DBMI portal.&lt;/li&gt;
&lt;li&gt;Sign the Rules of Conduct and the Data Use Agreement (the &lt;a href="https://n2c2.dbmi.hms.harvard.edu/files/n2c2/files/n2c2_2019_-_dua_track_3.pdf" rel="noopener noreferrer"&gt;n2c2 DUAs&lt;/a&gt; are real legal documents, not click-through checkboxes).&lt;/li&gt;
&lt;li&gt;Wait for a human to approve you. There is no published turnaround time.&lt;/li&gt;
&lt;li&gt;Repeat for every individual on your team, because the DUA is per-person, not per-lab.&lt;/li&gt;
&lt;li&gt;Never redistribute the data - which also means never shipping it as a test fixture, never putting it in CI, never publishing your evaluation harness with the inputs included.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is not villainy. The data is real patient prose; a DUA is the legally and ethically appropriate wrapper, and the people who built and maintain these corpora did the field an enormous service. But note the architecture: the standard benchmark for a global research area is administered by one team at one institution, through one portal, with per-user paperwork and no fallback. When that single point of distribution goes down - for maintenance, for a compliance review, for a staffing gap, for whatever is happening right now - the benchmark simply ceases to exist for anyone who doesn't already have a copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this hurts more than my project
&lt;/h2&gt;

&lt;p&gt;Play the incentives forward and the damage compounds:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Published results become unreproducible in practice.&lt;/strong&gt; A paper says "0.94 F1 on i2b2 2014." You cannot check that claim, cannot run the same test set through your own tool, cannot even eyeball the annotation decisions the score depends on. Reproducibility in de-id research is gated on a portal login, and today on a portal banner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New tools cannot compare against prior art.&lt;/strong&gt; The literature has twenty years of numbers on these corpora. A new open-source tool that cannot access them either skips comparison (and gets dismissed) or quotes other papers' numbers against its own results on different data (and misleads).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The canonical numbers fossilize.&lt;/strong&gt; Because making a new shareable gold standard from real notes is brutally expensive - the 2014 corpus took double annotation, arbitration, and multiple proofreading rounds to reach that 0.927 human F1 - nobody replaces the old benchmarks. The field's reference points are frozen in 2006 and 2014 while clinical documentation, and the models reading it, changed completely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Insiders and outsiders diverge.&lt;/strong&gt; Groups with long-standing access or local hospital data keep publishing; independent developers and open-source maintainers evaluate on whatever they can scrape together. The people most likely to ship a de-id tool you can actually download are the least able to prove it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternatives tour
&lt;/h2&gt;

&lt;p&gt;Before building anything, I did the diligence on every other door:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PhysioNet.&lt;/strong&gt; The MIMIC family of ICU databases includes clinical notes, and PhysioNet also hosts a &lt;a href="https://physionet.org/content/deidentifiedmedicaltext/1.0/" rel="noopener noreferrer"&gt;gold standard corpus of 2,434 de-identified nursing notes&lt;/a&gt; with realistic surrogate PHI (Neamatullah et al., 2008). Access requires becoming a credentialed user: identity verification, the CITI "Data or Specimens Only Research" training course, and then a separate DUA per dataset. To PhysioNet's credit, this process is documented, predictable, and actually functioning. But it is still weeks of process, still per-person, still non-redistributable. And MIMIC's notes are already de-identified with placeholders, so to evaluate a de-id tool you must first re-inject surrogate PHI - which is exactly what &lt;a href="https://arxiv.org/pdf/1803.02728" rel="noopener noreferrer"&gt;prior work on synthetically-identified MIMIC notes&lt;/a&gt; does. The "real data" path quietly becomes a synthetic-PHI path anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MTSamples.&lt;/strong&gt; &lt;a href="https://mtsamples.com/" rel="noopener noreferrer"&gt;MTSamples.com&lt;/a&gt; hosts thousands of publicly available transcribed sample medical reports across dozens of specialties. No registration, no DUA, real clinical language structure. The catch: they are samples, so they contain no PHI to find. To make a de-id benchmark out of them you inject fake PHI into the text - an approach with an established lineage in the literature. This is the honest public option, and it is the spirit my workaround follows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synthea.&lt;/strong&gt; &lt;a href="https://synthea.mitre.org/" rel="noopener noreferrer"&gt;Synthea&lt;/a&gt; generates fully synthetic patients with medically plausible histories - fantastic for structured FHIR data. But its narrative output is template-driven fill-in-the-blank SOAP text linked to the structured record. For de-id evaluation, where the entire game is the messiness of real prose - copy-paste artifacts, headers, inconsistent formatting, abbreviations - templated narrative is the wrong distribution by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Newer synthetic clinical text efforts.&lt;/strong&gt; This space is heating up: &lt;a href="https://www.frontiersin.org/journals/digital-health/articles/10.3389/fdgth.2025.1497130/full" rel="noopener noreferrer"&gt;Synthetic4Health&lt;/a&gt; generates annotated synthetic clinical letters; &lt;a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12926592/" rel="noopener noreferrer"&gt;ASQ-PHI&lt;/a&gt; proposes an adversarial synthetic benchmark specifically for de-identification; and there is active work on &lt;a href="https://www.nature.com/articles/s41598-025-86890-3" rel="noopener noreferrer"&gt;whether LLM-generated notes actually match real note distributions&lt;/a&gt; (early answer: imperfectly, and you should measure the gap rather than assume it away). None of these is yet a community-standard replacement for i2b2 2014. All of them are bets on the same thesis I ended up betting on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workaround: a synthetic corpus generator
&lt;/h2&gt;

&lt;p&gt;So I built my own synthetic corpus generator for the PHI de-identification tool I'm building. The design is deliberately boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Templates styled after real clinical note genres&lt;/strong&gt; - discharge summaries, progress notes, radiology reports - with the section structure, boilerplate, and formatting quirks those genres actually have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled PHI injection.&lt;/strong&gt; Fake names, MRNs, dates, phone numbers, addresses, providers, and facilities generated and inserted at known offsets. Every injected entity is recorded with its exact span and category at generation time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category balancing.&lt;/strong&gt; Real corpora are dominated by dates and names; rare categories (fax numbers, device IDs, URLs) barely appear. A generator can oversample the rare stuff so your recall numbers on those categories are backed by hundreds of instances instead of four.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The advantages are structural, not incidental:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Labeled by construction.&lt;/strong&gt; No annotation budget, no inter-annotator disagreement, no 0.927 ceiling on ground-truth quality. The generator knows where the PHI is because it put it there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shareable.&lt;/strong&gt; No DUA, because there is no patient. The corpus - and more importantly the generator - can live in a public repo, run in CI, and ship as test fixtures. Anyone can regenerate the exact evaluation set from the code and a seed. That is a property the i2b2 corpora can never have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalable and steerable.&lt;/strong&gt; Need 10,000 more notes with dates in weird formats? That's a parameter, not a grant application.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;Here is where I'm obligated to argue against myself, because synthetic evaluation has failure modes that will flatter your tool if you let them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution shift.&lt;/strong&gt; My templates are styled after clinical notes; they are not clinical notes. Real notes contain dictation artifacts, OCR junk, mid-sentence copy-paste, and formatting chaos that no template library fully reproduces. A recall number earned on synthetic text is an upper bound, not an estimate, of real-world recall. There is emerging work on &lt;a href="https://link.springer.com/article/10.1186/s44342-026-00072-9" rel="noopener noreferrer"&gt;quantifying exactly this synthetic-to-real gap for PHI taggers&lt;/a&gt;, and the gap is real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overfitting to your own generator.&lt;/strong&gt; This is the insidious one. If the same mental model writes both the fake-name generator and the name-detection logic, the benchmark and the tool share assumptions, and you are grading your own homework. Mitigations exist - independent sources for injection values, formats the detector wasn't designed around, adversarial edge cases - but the risk never reaches zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It complements real-data validation; it does not replace it.&lt;/strong&gt; My position after all this: synthetic corpora are for development, regression testing, category-level diagnostics, and public reproducibility. Before anyone trusts a de-id tool with actual patient data, it needs validation on real notes under proper governance - PhysioNet credentialing, an institutional dataset, or the n2c2 corpora if the door ever reopens. Remember, though, that even those gold standards are real prose with surrogate identifiers. The line between "real benchmark" and "synthetic benchmark" was always a spectrum, not a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  What better infrastructure would look like
&lt;/h2&gt;

&lt;p&gt;None of this requires new science. It requires plumbing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A status page and a mirror.&lt;/strong&gt; If a dataset is the reference benchmark for a field, "temporarily unavailable" with no ETA on a single portal should be impossible. PhysioNet already demonstrates the model: documented process, predictable credentialing, many datasets under one durable roof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standing distribution instead of single-lab stewardship.&lt;/strong&gt; Move canonical corpora to infrastructure whose job is distribution, with credentialing handled once per user, not per corpus per portal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish generators, not just corpora.&lt;/strong&gt; A community-maintained synthetic benchmark - generator code plus seeds, calibrated against real data - would give the field something no DUA can: an evaluation anyone can run, extend, and verify. The recent synthetic-benchmark papers are steps in this direction; what's missing is convergence on one that leaderboards accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-track evaluation as the norm.&lt;/strong&gt; Report on the gated real corpus for comparability, and on an open synthetic corpus for reproducibility. Either number alone is half a result.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the data before you plan the benchmark.&lt;/strong&gt; The canonical de-id corpora are behind a per-user DUA on one portal, and that portal's n2c2 datasets have been "temporarily unavailable" from at least 2026-07-28 through 2026-08-18, with the legacy i2b2.org page throwing HTTP 500. Verify the current state yourself; date what you find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-institution gatekeeping of a field's benchmark is a reliability bug&lt;/strong&gt;, independent of any outage. Per-user DUAs also mean no CI, no fixtures, no redistribution - reproducibility is structurally capped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The field's reference numbers are from 2006 and 2014.&lt;/strong&gt; Best strict F1 of 0.936 on 1,304 notes from one hospital system is still the bar new tools are measured against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PhysioNet is the functioning real-data path&lt;/strong&gt; - credentialing, CITI training, per-dataset DUA - but its notes need surrogate PHI re-injection for de-id evaluation anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic corpora buy you labels by construction, shareability, and category balance.&lt;/strong&gt; They cost you distribution realism, and they tempt you into grading your own homework. Use them for development and public reproducibility; validate on real data before production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gold standards were already part synthetic.&lt;/strong&gt; Real notes, surrogate PHI. Synthetic evaluation isn't a betrayal of rigor - unexamined synthetic evaluation is.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;If you work in clinical NLP: how did you get your benchmark data, and how long did access take? And if anyone has current information on when the n2c2 datasets are coming back, the comments are open.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>healthtech</category>
      <category>machinelearning</category>
      <category>nlp</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Is 'HIPAA-Eligible' the Same as 'HIPAA-Compliant'? I Audited AWS's List Against the Security Rule</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Tue, 18 Aug 2026 03:53:27 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/is-hipaa-eligible-the-same-as-hipaa-compliant-i-audited-awss-list-against-the-security-rule-4alf</link>
      <guid>https://dev.to/vadim_albarov/is-hipaa-eligible-the-same-as-hipaa-compliant-i-audited-awss-list-against-the-security-rule-4alf</guid>
      <description>&lt;p&gt;Here is a sentence I have heard, in some form, from three different engineers and one vendor sales deck: "We're fine, we only use HIPAA-eligible AWS services."&lt;/p&gt;

&lt;p&gt;I build healthcare infrastructure for a living, and every time I hear it I do the same mental translation: "We're fine, our data center signed a contract." It's true, it's necessary, and it answers roughly none of the questions a HIPAA auditor will ask you.&lt;/p&gt;

&lt;p&gt;So I decided to make the gap measurable. I took six AWS services that show up in practically every PHI-handling stack - S3, RDS for PostgreSQL, ECS on Fargate, KMS, the CloudTrail/CloudWatch audit pair, and the ALB - and audited each against the Security Rule's technical safeguards (&lt;a href="https://www.law.cornell.edu/cfr/text/45/164.312" rel="noopener noreferrer"&gt;45 CFR 164.312&lt;/a&gt;): what the regulation demands, what the service does by default, and what you must configure yourself.&lt;/p&gt;

&lt;p&gt;Spoiler: of the 35 concrete controls I mapped, the defaults covered 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "HIPAA-eligible" actually means
&lt;/h2&gt;

&lt;p&gt;Start with what AWS itself says. The &lt;a href="https://aws.amazon.com/compliance/hipaa-eligible-services-reference/" rel="noopener noreferrer"&gt;HIPAA-eligible services reference&lt;/a&gt; defines eligible services as those that may "create, receive, process, maintain, or transmit" electronic protected health information (ePHI) - &lt;em&gt;provided&lt;/em&gt; you have signed AWS's Business Associate Addendum (BAA) first. And then, in plain sight, the sentence everyone skips: "Customers still must configure these services consistent with HIPAA requirements."&lt;/p&gt;

&lt;p&gt;The fast primer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;covered entity&lt;/strong&gt; (provider, health plan, clearinghouse) or a &lt;strong&gt;business associate&lt;/strong&gt; (anyone handling PHI for them - your SaaS included, if hospitals are your customers) must comply with HIPAA. AWS becomes &lt;em&gt;your&lt;/em&gt; business associate when you sign the BAA - today a self-service click in AWS Artifact.&lt;/li&gt;
&lt;li&gt;The BAA covers AWS's side of the &lt;a href="https://aws.amazon.com/compliance/shared-responsibility-model/" rel="noopener noreferrer"&gt;shared responsibility model&lt;/a&gt;: data centers, hypervisors, service internals - security &lt;em&gt;of&lt;/em&gt; the cloud. Everything you configure - security &lt;em&gt;in&lt;/em&gt; the cloud - stays yours.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Security Rule&lt;/strong&gt; is the part of HIPAA that talks to engineers. Its technical safeguards (&lt;a href="https://www.law.cornell.edu/cfr/text/45/164.312" rel="noopener noreferrer"&gt;164.312&lt;/a&gt;) name five standards: access control, audit controls, integrity, person or entity authentication, and transmission security.&lt;/li&gt;
&lt;li&gt;Implementation specifications are &lt;strong&gt;required&lt;/strong&gt; or &lt;strong&gt;addressable&lt;/strong&gt;. Addressable does not mean optional - it means implement it, implement a documented equivalent, or document a defensible reason you did neither. Encryption at rest and in transit are both addressable, and every healthcare shop I know treats them as required: "we decided not to encrypt the PHI" is not a paragraph anyone wants to defend in a breach investigation.&lt;/li&gt;
&lt;li&gt;And the kicker, straight from &lt;a href="https://aws.amazon.com/compliance/hipaa-compliance/" rel="noopener noreferrer"&gt;AWS's own HIPAA compliance page&lt;/a&gt;: "There is no HIPAA certification for a cloud service provider (CSP) such as AWS." There is no certification for you either. Only your configuration, your documentation, and eventually someone's audit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "HIPAA-eligible" means exactly one thing: the service is covered by AWS's BAA, so AWS's side of the split is contractually handled. Whether &lt;em&gt;your&lt;/em&gt; side is handled is the rest of this article.&lt;/p&gt;

&lt;p&gt;Last year I distilled a few years of building this into a set of internal Terraform modules for my team - one hardened block per service, each control traced to its Security Rule citation. The most useful artifact of that project was the diff between "what the module enforces" and "what AWS gave us out of the box." That diff is essentially this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit
&lt;/h2&gt;

&lt;p&gt;Per service: what 164.312 asks, what you get by default, what you must add. Every default claim is sourced; where a default changed recently, I say when.&lt;/p&gt;

&lt;h3&gt;
  
  
  S3 - the poster child of improved-but-insufficient
&lt;/h3&gt;

&lt;p&gt;Credit where due: S3's defaults have genuinely improved. Since &lt;a href="https://aws.amazon.com/about-aws/whats-new/2023/01/amazon-s3-automatically-encrypts-new-objects" rel="noopener noreferrer"&gt;January 2023&lt;/a&gt;, every new object is encrypted with SSE-S3, and you cannot turn that off. Since &lt;a href="https://aws.amazon.com/about-aws/whats-new/2023/04/amazon-s3-security-best-practices-buckets-default" rel="noopener noreferrer"&gt;April 2023&lt;/a&gt;, new buckets get all four Block Public Access settings enabled and ACLs disabled. Two real 164.312(a) wins, for free. Now the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transmission security (164.312(e), required standard):&lt;/strong&gt; S3 happily serves plaintext HTTP unless you attach a bucket policy denying &lt;code&gt;aws:SecureTransport = false&lt;/code&gt;. Nothing does this for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity (164.312(c)):&lt;/strong&gt; versioning - your recovery path when an object is improperly altered or deleted - is off by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit controls (164.312(b)):&lt;/strong&gt; server access logging is off by default, and object-level API logging (who read which object) doesn't come from S3 at all - it needs CloudTrail data events, also off by default (see CloudTrail below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption, but accountably:&lt;/strong&gt; SSE-S3 encrypts with a key you can't scope, rotate on your terms, or audit the use of. For PHI you want SSE-KMS with a customer-managed key, plus a policy denying uploads that request the wrong key - so a misconfigured client fails loudly instead of writing PHI under the wrong crypto.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One non-obvious lesson from building the S3 module: keep patient identifiers out of object keys. Keys leak into access logs, CloudTrail events, error messages, and URLs - surfaces your PHI inventory forgot about.&lt;/p&gt;

&lt;h3&gt;
  
  
  RDS for PostgreSQL - encryption is opt-in, still, in 2026
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encryption at rest (164.312(a)(2)(iv)):&lt;/strong&gt; &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html" rel="noopener noreferrer"&gt;storage encryption is not enabled by default&lt;/a&gt; at the API level - &lt;code&gt;StorageEncrypted&lt;/code&gt; defaults to false - and can only be set at creation time. Forget it in your Terraform, and the fix is a snapshot-copy-restore migration, not a flag flip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transmission security (164.312(e)):&lt;/strong&gt; a moving target. For &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/PostgreSQL.Concepts.General.SSL.html" rel="noopener noreferrer"&gt;RDS PostgreSQL 15 and later, &lt;code&gt;rds.force_ssl&lt;/code&gt; defaults to 1&lt;/a&gt;; for 14 and older it defaults to 0. It also lives in a parameter group, one console edit away from silently becoming 0 again. In my modules it's pinned at the engine, so a client that forgets &lt;code&gt;sslmode=require&lt;/code&gt; gets an error instead of a plaintext session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control (164.312(a)(1)):&lt;/strong&gt; nothing stops you from making the instance publicly accessible or dropping it in a public subnet. Private placement, security-group-to-security-group ingress (no CIDR allowlists - identities beat address ranges), and &lt;code&gt;publicly_accessible = false&lt;/code&gt; are all decisions you must make and enforce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity (164.312(c)):&lt;/strong&gt; &lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_DeleteInstance.html" rel="noopener noreferrer"&gt;deletion protection is disabled by default in the API and IaC&lt;/a&gt; (the console pre-checks it for production templates - your safety net depends on which tool created the database).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication (164.312(d)) and audit (164.312(b)):&lt;/strong&gt; RDS &lt;em&gt;can&lt;/em&gt; generate and rotate the master password in Secrets Manager so it never touches Terraform state; it can export connection logs to CloudWatch. Both are opt-in. By default the master password is wherever you put it, and connection history is a shrug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ECS on Fargate - the good defaults are the invisible ones
&lt;/h3&gt;

&lt;p&gt;Fargate's best HIPAA property doesn't appear in any console checkbox: there are no instances. No AMIs to patch, no SSH, no node agents - a whole slab of the malicious-software surface moves to AWS's side of the BAA. That and IAM's deny-by-default (a fresh task role can do nothing) are real defaults in your favor. Everything else is on you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access control (164.312(a)(1)):&lt;/strong&gt; nothing prevents tasks in public subnets with public IPs. Private placement is a choice you codify.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication (164.312(d)):&lt;/strong&gt; plain environment variables appear in &lt;code&gt;DescribeTaskDefinition&lt;/code&gt; output and the console. Database credentials must go through the task definition's &lt;code&gt;secrets&lt;/code&gt; integration with Secrets Manager - injected at container start, never written down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique identity (164.312(a)(2)(i)):&lt;/strong&gt; the execution role (ECS pulling images, injecting secrets) and the task role (your app) are separate identities - if you bother creating them separately instead of reusing one fat role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit controls (164.312(b)):&lt;/strong&gt; my favorite gap in this audit. ECS Exec - an interactive shell inside a container that may hold PHI in memory - records, by default, only the &lt;code&gt;ExecuteCommand&lt;/code&gt; API call in CloudTrail. What happened &lt;em&gt;inside&lt;/em&gt; the session goes unrecorded unless you configure session I/O logging to an encrypted log group. A shell in a PHI container is an access event; out of the box, it's one with no transcript.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  KMS - the service that only helps if you invite it
&lt;/h3&gt;

&lt;p&gt;KMS is HIPAA-eligible, excellent, and entirely opt-in. Every encryption integration on this page uses KMS &lt;em&gt;if you wire it in&lt;/em&gt;; the eligible list has no opinion about whether you did. Even once you create customer-managed keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html" rel="noopener noreferrer"&gt;Automatic key rotation is disabled by default&lt;/a&gt; for customer-managed keys (AWS-managed keys rotate yearly; the keys you control don't, until you say so).&lt;/li&gt;
&lt;li&gt;The default key policy hands the account root full access and calls it a day. Separating key &lt;em&gt;administration&lt;/em&gt; (policy, rotation, deletion - no decrypt) from key &lt;em&gt;use&lt;/em&gt; (decrypt, no administration) is a policy you write yourself. Worth writing: no single identity can then both reconfigure the crypto and read the data - exactly what 164.312(a)(1) wants from an access control story.&lt;/li&gt;
&lt;li&gt;One default that genuinely protects you: key deletion requires a 7-30 day waiting period. Deleting a key that ever encrypted PHI destroys the data with it, so in my modules the window is pinned to the 30-day maximum and &lt;code&gt;ScheduleKeyDeletion&lt;/code&gt; events page a human.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One design decision I'd defend anywhere: separate keys per purpose - data, logs, backups. Revoking access to PHI data should never break log delivery, and CloudTrail can hold the logs key without gaining any path to the data key.&lt;/p&gt;

&lt;h3&gt;
  
  
  CloudTrail and CloudWatch - the audit trail you think you have
&lt;/h3&gt;

&lt;p&gt;164.312(b) is a required standard with no addressable escape hatch: "implement... mechanisms that record and examine activity in information systems that contain or use ePHI." What you have by default is &lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/view-cloudtrail-events.html" rel="noopener noreferrer"&gt;90 days of management-event history&lt;/a&gt; in the console. That's it. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No durable trail. For records past 90 days you must create a trail delivering to S3. HIPAA's documentation retention requirement (164.316(b)(2)(i)) is six years; most programs apply the same horizon to audit artifacts. 90 days is not in the same universe.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/how-cloudtrail-works.html" rel="noopener noreferrer"&gt;Data events are not logged by default&lt;/a&gt;. Management events tell you someone changed a bucket policy; they do &lt;em&gt;not&lt;/em&gt; tell you someone downloaded 10,000 patient records via &lt;code&gt;GetObject&lt;/code&gt;. Object-level audit is a separate, per-event-billed opt-in - in my modules it's a flag with a cost warning, but a flag someone consciously declines, not a silence nobody noticed.&lt;/li&gt;
&lt;li&gt;Log file validation (the SHA-256 digest chain that makes trail tampering detectable - the 164.312(c) integrity story for the audit trail itself) is a setting, not a given.&lt;/li&gt;
&lt;li&gt;On the CloudWatch side: log groups &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/Working-with-log-groups-and-streams.html" rel="noopener noreferrer"&gt;never expire by default&lt;/a&gt; - retention is a policy you set deliberately - and while log data is SSE-encrypted at rest, &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html" rel="noopener noreferrer"&gt;customer-managed KMS encryption is optional&lt;/a&gt;, attached per log group.&lt;/li&gt;
&lt;li&gt;And the quiet half of 164.312(b): "record &lt;em&gt;and examine&lt;/em&gt;." Metric filters, alarms, anything that turns the trail into a signal a human reviews - none of it exists until you build it. An unread audit trail satisfies nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ALB - TLS quality depends on which tool you clicked
&lt;/h3&gt;

&lt;p&gt;The load balancer is your transmission security (164.312(e)) chokepoint, with the most path-dependent defaults of the lot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create an HTTPS listener in the console today and you get a modern TLS 1.3/1.2 policy. Create it through the &lt;a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/describe-ssl-policies.html" rel="noopener noreferrer"&gt;CLI or CloudFormation and the default is &lt;code&gt;ELBSecurityPolicy-2016-08&lt;/code&gt;&lt;/a&gt;, which still accepts TLS 1.0 and 1.1. Your cipher floor depends on which tool provisioned the listener. Pin the policy explicitly and this stops being interesting.&lt;/li&gt;
&lt;li&gt;Nothing forces HTTPS to exist at all. A port-80 listener forwarding plaintext is perfectly deployable; the 80-to-443 redirect is a choice.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-access-logs.html" rel="noopener noreferrer"&gt;Access logs are disabled by default&lt;/a&gt; - there goes 164.312(b) at the edge until you wire up the log bucket.&lt;/li&gt;
&lt;li&gt;Deletion protection: off by default. Deleting the front door of a PHI service should take two steps.&lt;/li&gt;
&lt;li&gt;And one my team debated for a week: by default TLS terminates at the ALB, and the hop to your tasks is HTTP inside the private network. A common, defensible posture - but a &lt;em&gt;documented risk decision&lt;/em&gt; your analysis has to own, not something the eligible list settled for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;p&gt;Counting controls involves judgment calls, so here's mine - each row counts the specific 164.312-relevant controls discussed above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Controls mapped&lt;/th&gt;
&lt;th&gt;Covered by default&lt;/th&gt;
&lt;th&gt;You configure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;2 (SSE-S3 since 2023, Block Public Access since 2023)&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS Postgres&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1 (forced TLS, PG15+ only)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ECS Fargate&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2 (no node surface, IAM deny-by-default)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KMS&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1 (deletion waiting period)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudTrail / CloudWatch&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0 (90-day history is partial credit at best)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Roughly one control in six arrives configured. The other 29 are your job, on services that are all on the HIPAA-eligible list. And notice &lt;em&gt;which&lt;/em&gt; six: the freebies cluster in encryption at rest, where AWS has spent a decade raising defaults. Audit controls and transmission security - both required standards - score near zero.&lt;/p&gt;

&lt;p&gt;The floor is genuinely rising: S3 in 2023, PostgreSQL 15's &lt;code&gt;force_ssl&lt;/code&gt;, console TLS policies. But that's also the trap - your actual defaults depend on the year the feature shipped, the engine version, and whether the resource was born in the console or in CloudFormation. A compliance posture made of remembered defaults is a compliance posture made of trivia.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it: treat the list as a floor
&lt;/h2&gt;

&lt;p&gt;The eligible list answers one question: may PHI touch this service under our BAA? Everything after that yes is configuration, and configuration you rely on should be code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Codify the invariants, and don't make them variables.&lt;/strong&gt; The rule that survived every review of our modules: if the Security Rule requires it, there is no toggle. Storage encryption, TLS enforcement, log validation, private placement - hardcoded. Variables exist only for things a compliant deployment may legitimately vary, like retention above the floor. A toggle someone can forget is a finding someone will write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let policy-as-code catch the drift.&lt;/strong&gt; &lt;a href="https://www.checkov.io/" rel="noopener noreferrer"&gt;checkov&lt;/a&gt; and &lt;a href="https://github.com/terraform-linters/tflint" rel="noopener noreferrer"&gt;tflint&lt;/a&gt; in CI flag most of the gaps in this article - unencrypted RDS, missing bucket policies, permissive TLS policies - before they exist. Native &lt;code&gt;terraform test&lt;/code&gt; can pin the invariants so a refactor can't quietly reintroduce a toggle. Where you do deviate, suppress with a written justification inline; that comment &lt;em&gt;is&lt;/em&gt; your addressable-specification documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map controls to citations, in the repo.&lt;/strong&gt; Every module of ours carries a table: citation, what the code enforces, and - the column that earns its keep - &lt;em&gt;what remains yours&lt;/em&gt;. No amount of Terraform covers the administrative safeguards: risk analysis, a log review procedure someone actually follows, restore testing, access reviews, BAAs with every other PHI-touching vendor. The code can only make the technical floor solid enough for your people to stand on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"HIPAA-eligible" was never a lie. It's a contract term that got promoted, somewhere between the sales deck and the standup, into a security property. The list tells you where PHI may go. The Security Rule tells you what must be true when it gets there. The distance between the two is not covered by anyone's BAA - it's covered by you, ideally in version control.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclaimer: I'm an infrastructure engineer, not a lawyer; this is engineering analysis of published regulations and AWS documentation, not legal advice. Defaults cited were verified in August 2026 and do change (sometimes for the better). Run the details past your compliance officer, who will find at least one thing here that your specific situation makes wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the widest eligible-vs-compliant gap you've hit in the wild? I collect these.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>hipaa</category>
      <category>security</category>
      <category>healthtech</category>
    </item>
    <item>
      <title>"Oops, I Forgot to Tell You That's Dangerous": Claude Code Watched Me Wipe Production Redis - Then Helped Carve It Back Off the Disk"</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Wed, 12 Aug 2026 03:47:47 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/oops-i-forgot-to-tell-you-thats-dangerous-claude-code-watched-me-wipe-production-redis-then-300h</link>
      <guid>https://dev.to/vadim_albarov/oops-i-forgot-to-tell-you-thats-dangerous-claude-code-watched-me-wipe-production-redis-then-300h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I enabled Redis AOF persistence by editing &lt;code&gt;redis.conf&lt;/code&gt; and restarting the service. On the test environment it worked fine. On production, all three nodes came back with &lt;strong&gt;zero keys&lt;/strong&gt;, and the empty dataset overwrote &lt;code&gt;dump.rdb&lt;/code&gt;. The missed step: you must enable AOF on the &lt;strong&gt;running&lt;/strong&gt; instance with &lt;code&gt;CONFIG SET appendonly yes&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; putting it in the config file. We got the data back by carving the unlinked RDB file out of raw disk blocks with &lt;code&gt;dd&lt;/code&gt;. Oh, and the AI assistant that helped me plan the change knew about this trap the whole time - it just didn't mention it until &lt;em&gt;after&lt;/em&gt; the wipe. Here's the full story.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Some context first
&lt;/h2&gt;

&lt;p&gt;The stage for this story is a legacy healthcare project that's been running in production for years. Redis sits at the center of it, wearing two hats: it's the cache in front of SQL Server, and it's also the &lt;strong&gt;feature flag storage&lt;/strong&gt; - the backend seeds flags into Redis at startup, and both backend and client read them from there at runtime. Over the years the flag storage format evolved (more on that in a minute), the cache quietly accumulated datasets that exist &lt;em&gt;only&lt;/em&gt; in Redis, and the Redis setup itself - a bare tarball install with an untouched config - predates everyone's memory of who set it up. In other words: exactly the kind of system where nobody looks at the persistence settings until something forces them to.&lt;/p&gt;

&lt;p&gt;Something did.&lt;/p&gt;

&lt;h2&gt;
  
  
  It started with a feature flag that "flipped itself"
&lt;/h2&gt;

&lt;p&gt;After we published a new release, one of our feature flags apparently turned itself on. Nobody had touched it. The flag had existed for months with a default of &lt;code&gt;false&lt;/code&gt;, and suddenly the client started behaving as if it were &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I diffed the two release tags first: zero changes to flag definitions or defaults. So I went to where the flags actually live in Redis - and found the flag stored in &lt;strong&gt;two places&lt;/strong&gt;, disagreeing with each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redis-cli GET  FeatureFlag:CrossWindowCommands   → &lt;span class="s2"&gt;"true"&lt;/span&gt;    &lt;span class="c"&gt;# legacy string key&lt;/span&gt;
redis-cli HGET FeatureFlag CrossWindowCommands   → &lt;span class="s2"&gt;"false"&lt;/span&gt;   &lt;span class="c"&gt;# current hash field&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current implementation stores flags as fields in a &lt;code&gt;FeatureFlag&lt;/code&gt; hash, but there was also a &lt;strong&gt;stale legacy string key&lt;/strong&gt; left over from the old storage format. The &lt;code&gt;false&lt;/code&gt; in the hash, it turned out, wasn't the original state at all - a teammate had dug into the same problem before me and manually set it back to &lt;code&gt;false&lt;/code&gt;. Which made the mismatch the real hint: the legacy key still held &lt;code&gt;true&lt;/code&gt;, untouched, and the values in Redis always win - the startup migration copies legacy keys into the hash with &lt;code&gt;HSETNX&lt;/code&gt;, and the config default only applies when the field doesn't exist yet.&lt;/p&gt;

&lt;p&gt;So the explanation was less dramatic than a flag flipping itself: the flag had been &lt;code&gt;true&lt;/code&gt; in Redis all along. It just didn't matter, because for months no code path ever read it. Then the new client release shipped code that actually &lt;em&gt;did something&lt;/em&gt; with the flag, and a value that had been harmlessly wrong the whole time suddenly had teeth. An unused flag quietly became a used one, and it looked like a spontaneous flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real finding: our persistence was a time bomb
&lt;/h2&gt;

&lt;p&gt;While investigating, I checked how the Redis instance itself was configured. It's a redis-stack tarball install running natively on RHEL under a custom systemd unit - no Docker, no operator, one master and two replicas with Sentinel. The persistence settings were all defaults - nothing configured at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;appendonly&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt;                    &lt;span class="c"&gt;# default
&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;     &lt;span class="c"&gt;# default snapshot thresholds
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At our write volume, that works out to roughly one snapshot an hour. So every restart could silently roll the dataset back by up to an hour - and worse, a rollback could resurrect old values (like stale legacy flag keys) that had been deleted since the last snapshot. That's exactly the kind of environment where flags "change themselves" and nobody can explain why.&lt;/p&gt;

&lt;p&gt;So we made a decision: park the whodunit, fix the root cause. &lt;strong&gt;Enable AOF&lt;/strong&gt; (append-only file). With &lt;code&gt;aof-timestamp-enabled yes&lt;/code&gt; you even get a timestamped log of every write command - a free audit trail for exactly this class of mystery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;appendonly&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;span class="n"&gt;appendfsync&lt;/span&gt; &lt;span class="n"&gt;everysec&lt;/span&gt;
&lt;span class="n"&gt;aof&lt;/span&gt;-&lt;span class="n"&gt;timestamp&lt;/span&gt;-&lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
&lt;span class="n"&gt;auto&lt;/span&gt;-&lt;span class="n"&gt;aof&lt;/span&gt;-&lt;span class="n"&gt;rewrite&lt;/span&gt;-&lt;span class="n"&gt;min&lt;/span&gt;-&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="n"&gt;mb&lt;/span&gt;
&lt;span class="n"&gt;auto&lt;/span&gt;-&lt;span class="n"&gt;aof&lt;/span&gt;-&lt;span class="n"&gt;rewrite&lt;/span&gt;-&lt;span class="n"&gt;percentage&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Honest caveat: it's a &lt;em&gt;what-and-when&lt;/em&gt; audit, not a &lt;em&gt;who&lt;/em&gt; - the AOF doesn't record which client or user issued a command. Since this instance effectively has a single user anyway, that was good enough for a start.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing: everything green
&lt;/h2&gt;

&lt;p&gt;I tested the change end to end. First in a throwaway Docker container: enabled AOF, wrote some flags, and confirmed the AOF captured every command with &lt;code&gt;#TS:&lt;/code&gt; timestamps. I even benchmarked it: 100,000-op &lt;code&gt;redis-benchmark&lt;/code&gt; runs against two identical containers came back at ~181k SET/s &lt;em&gt;with&lt;/em&gt; AOF versus ~142k without. Yes, the AOF run scored higher - which tells you the difference is pure run-to-run noise. With &lt;code&gt;appendfsync everysec&lt;/code&gt;, the write-throughput cost is unmeasurable.&lt;/p&gt;

&lt;p&gt;Then on the test environment cluster. I enabled it &lt;strong&gt;live&lt;/strong&gt; on the running instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redis-cli CONFIG SET appendonly &lt;span class="nb"&gt;yes
&lt;/span&gt;redis-cli CONFIG SET aof-timestamp-enabled &lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did that live purely to check the concept: right after flipping it I changed a couple of values and watched them show up in the AOF file. A quick sanity check, nothing more - or so I thought. I had no idea this throwaway "test" step was the one doing the heavy lifting.&lt;/p&gt;

&lt;p&gt;I then added the same settings to &lt;code&gt;redis.conf&lt;/code&gt; so they'd survive a restart, rebooted the nodes one by one, and verified. Everything held: &lt;code&gt;aof_enabled:1&lt;/code&gt;, data intact, dummy writes visible in &lt;code&gt;appendonlydir/*.incr.aof&lt;/code&gt;, timestamps and all.&lt;/p&gt;

&lt;p&gt;The test environment was perfect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production: 0 keys
&lt;/h2&gt;

&lt;p&gt;On production I did what felt like the same change: edited &lt;code&gt;redis.conf&lt;/code&gt; on all three nodes, added &lt;code&gt;appendonly yes&lt;/code&gt;, stopped the sentinels (deliberately, so no surprise failovers mid-maintenance), and rebooted the nodes one by one.&lt;/p&gt;

&lt;p&gt;Why full reboots instead of just restarting the Redis service? Two reasons that felt responsible at the time. All three servers had quietly accumulated &lt;strong&gt;500+ pending OS updates&lt;/strong&gt;, including some severe security patches - so since I was already in a maintenance window, it seemed silly not to apply them and reboot in one go. And a real restart was part of the plan anyway: the whole point of putting &lt;code&gt;appendonly yes&lt;/code&gt; into the config file was to prove the setting survives a node going down, so I wanted to see it hold through a genuine reboot.&lt;/p&gt;

&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;redis-cli DBSIZE
(integer) 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty. Master and both replicas. And &lt;code&gt;dump.rdb&lt;/code&gt; on disk? Also empty - about 100 bytes of RDB header and nothing else. Roughly 290,000 production keys, gone: cache, feature flags, and one Redis-only ID-mapping dataset that has no SQL fallback at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missed step
&lt;/h2&gt;

&lt;p&gt;Here's the trap, and if you take one thing from this article, take this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When Redis 7 starts with &lt;code&gt;appendonly yes&lt;/code&gt;, it loads the dataset from the AOF - and ignores &lt;code&gt;dump.rdb&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the test environment I had run &lt;code&gt;CONFIG SET appendonly yes&lt;/code&gt; on the &lt;em&gt;live&lt;/em&gt; instance first. That triggers an AOF rewrite that builds the AOF base file &lt;strong&gt;from the data currently in memory&lt;/strong&gt;. By the time the test environment restarted, a fully populated AOF existed on disk, and Redis restored from it.&lt;/p&gt;

&lt;p&gt;On production I skipped straight to the config file. So the first startup with &lt;code&gt;appendonly yes&lt;/code&gt; found no AOF containing the dataset, initialized an empty one, and came up blank. From there the damage compounds automatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Redis starts empty and creates an empty AOF base.&lt;/li&gt;
&lt;li&gt;The next background save writes the empty dataset &lt;strong&gt;over &lt;code&gt;dump.rdb&lt;/code&gt;&lt;/strong&gt; - and Redis replaces the file via write-temp-then-&lt;code&gt;rename()&lt;/code&gt;, so the original file's inode is unlinked.&lt;/li&gt;
&lt;li&gt;Replicas reconnect and do a full resync &lt;strong&gt;from the empty master&lt;/strong&gt;, wiping themselves too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three nodes of redundancy, all faithfully replicating nothing. (Replication protects you from machine failure. It does not protect you from yourself - it &lt;em&gt;distributes&lt;/em&gt; your mistake.)&lt;/p&gt;

&lt;p&gt;Full disclosure: I wasn't doing this alone. I had an AI assistant (Claude) in the loop for the whole change - it designed the test rehearsal, wrote the config, and later drove the recovery. It knew about this trap. It just never said it out loud before the prod run. Here's the moment it understood what had happened, opening with its own mea culpa:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzw11y6eo3n78bj3b5bf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzw11y6eo3n78bj3b5bf.png" alt="The moment Claude diagnosed the wipe — and apologized for not flagging the trap before the prod run"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is the classic - and brutal - AOF-enablement trap, and I should have flagged it explicitly for prod: when Redis starts with &lt;code&gt;appendonly yes&lt;/code&gt; in the conf but no AOF files exist yet, it loads from the (nonexistent) AOF and ignores &lt;code&gt;dump.rdb&lt;/code&gt; entirely - it starts empty and creates an empty AOF. On the test environment this didn't bite because you ran &lt;code&gt;CONFIG SET appendonly yes&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; rebooting - that built the AOF from the live dataset, so the reboot loaded it. On prod the conf was edited and rebooted directly - first boot came up empty, the next background save overwrote &lt;code&gt;dump.rdb&lt;/code&gt; with the empty dataset, and each replica that reconnected did a full resync from the empty master and wiped itself too. That's how all three ended up at 0 keys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A correct diagnosis in one message - six and a half hours too late to be a warning. (To be fair: I never asked "what could go wrong with this rollout?" either. Neither of us rehearsed the failure mode; we only rehearsed success.)&lt;/p&gt;

&lt;p&gt;One honest footnote from the later forensics: block-level evidence showed that at one point during the rollout an AOF base &lt;em&gt;with&lt;/em&gt; the full dataset existed on disk for a while, and the actual wipe most likely happened on a subsequent restart in the sequence. The exact fatal moment is unrecoverable; the end state was unambiguous - empty AOF, empty RDB, empty replicas.&lt;/p&gt;

&lt;p&gt;And of course: no backup copy of &lt;code&gt;dump.rdb&lt;/code&gt; taken before the change, and no off-box backups. (I know. I know.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The recovery: your data is probably still on the disk
&lt;/h2&gt;

&lt;p&gt;Here's the insight that saved us: because Redis replaces &lt;code&gt;dump.rdb&lt;/code&gt; via &lt;code&gt;rename()&lt;/code&gt;, the old file wasn't overwritten in place. Its inode was unlinked, but the &lt;strong&gt;data blocks were still sitting in the free space of the filesystem&lt;/strong&gt;, waiting to be reclaimed.&lt;/p&gt;

&lt;p&gt;So the plan became: stop all writes, and go dig through the raw block device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 - freeze everything.&lt;/strong&gt; Stop Redis, don't reboot, minimize writes to the filesystem. Every write is a chance for the filesystem to reclaim the very blocks you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 - try an LVM snapshot.&lt;/strong&gt; The volume group had zero free extents, so no snapshot headroom. Plan B.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 - scan the block device for RDB signatures.&lt;/strong&gt; Every RDB file starts with the magic bytes &lt;code&gt;REDIS00&lt;/code&gt;. The obvious approach dies immediately on a 2 GB RAM box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-abo&lt;/span&gt; &lt;span class="s1"&gt;'REDIS00'&lt;/span&gt; /dev/mapper/rhel-root
&lt;span class="nb"&gt;grep&lt;/span&gt;: memory exhausted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Block devices have no newlines; grep tries to buffer one infinite "line".) So: a tiny Python scanner that reads the device in 32 MB chunks with an overlap of the pattern length, and prints the absolute byte offset of every match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DEV&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dev/mapper/rhel-root&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PAT&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REDIS00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;CHUNK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DEV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buffering&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;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&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;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHUNK&lt;/span&gt;&lt;span class="p"&gt;)&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;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&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;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&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="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PAT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&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="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PAT&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;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;prev&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PAT&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="c1"&gt;# overlap so boundary-spanning hits aren't missed
&lt;/span&gt;        &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&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;base&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&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; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;CHUNK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;... scanned &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;sudo&lt;/code&gt; and &lt;code&gt;nohup&lt;/code&gt;, go make coffee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 - triage the hits.&lt;/strong&gt; The scan found 10 candidates. Each got a 128-byte peek, read-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/mapper/rhel-root &lt;span class="nv"&gt;iflag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;skip_bytes &lt;span class="nv"&gt;skip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$OFFSET&lt;/span&gt; &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;128 &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 | &lt;span class="nb"&gt;od&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hits were, hilariously, the scanner's own output files (the data directory shared the LV we were scanning - which is also why carve output had to be shipped off-box). Four were post-incident debris: the empty AOF bases and near-empty dumps left behind by the wipe. That left four candidates of 35-38 MB with real data, distinguishable by the timestamps embedded in their headers: the last pre-incident &lt;code&gt;dump.rdb&lt;/code&gt;, and AOF base files from during the incident window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 - carve the candidates to another machine.&lt;/strong&gt; Never write recovery output to the disk you're recovering from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo dd &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/mapper/rhel-root &lt;span class="nv"&gt;iflag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;skip_bytes &lt;span class="nv"&gt;skip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;59609546752 &lt;span class="nv"&gt;bs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1M &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;64 &lt;span class="se"&gt;\&lt;/span&gt;
  | ssh user@rescue-box &lt;span class="s1"&gt;'cat &amp;gt; /home/redis/cand_D.rdb'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 6 - validate.&lt;/strong&gt; &lt;code&gt;redis-check-rdb&lt;/code&gt; on the first candidate reported a CRC error - a block near the tail of the file had already been partially reclaimed - but it still parsed all 290,826 keys. Usable in an emergency, so we set it aside and kept going. The next candidate, the freshest one, came back clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;redis-check-rdb cand_D.rdb
&lt;span class="go"&gt;[offset 39730397] Checksum OK
[offset 39730397] \o/ RDB looks OK! \o/
[info] 290834 keys read
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 7 - verify the contents in a sandbox.&lt;/strong&gt; Never point production at an unverified file. A throwaway instance on the rescue box, isolated port, no config inheritance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redis-server &lt;span class="nt"&gt;--port&lt;/span&gt; 6391 &lt;span class="nt"&gt;--dir&lt;/span&gt; /home/redis/rescue-test &lt;span class="nt"&gt;--dbfilename&lt;/span&gt; dump.rdb &lt;span class="se"&gt;\&lt;/span&gt;
             &lt;span class="nt"&gt;--appendonly&lt;/span&gt; no &lt;span class="nt"&gt;--daemonize&lt;/span&gt; &lt;span class="nb"&gt;yes
&lt;/span&gt;redis-cli &lt;span class="nt"&gt;-p&lt;/span&gt; 6391 DBSIZE
redis-cli &lt;span class="nt"&gt;-p&lt;/span&gt; 6391 HGETALL FeatureFlag
redis-cli &lt;span class="nt"&gt;-p&lt;/span&gt; 6391 shutdown nosave
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real keys, real flag values. We had our database back - carved out of unallocated disk blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it back - carefully this time
&lt;/h2&gt;

&lt;p&gt;Restoring into a Sentinel topology has its own trap: if a sentinel promotes a replica that still holds the &lt;em&gt;empty&lt;/em&gt; dataset while you're restoring the master, replication will happily sync the emptiness right back over your restored data. So the order was strict:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm sentinels are stopped on &lt;strong&gt;all&lt;/strong&gt; nodes, then stop replicas, then the master.&lt;/li&gt;
&lt;li&gt;On the master: move the poisoned artifacts aside - never delete evidence mid-incident:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nb"&gt;mv &lt;/span&gt;appendonlydir appendonlydir.bad
   &lt;span class="nb"&gt;mv &lt;/span&gt;dump.rdb dump.rdb.empty
   &lt;span class="nb"&gt;cp &lt;/span&gt;cand_D.rdb dump.rdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;appendonly no&lt;/code&gt; in &lt;code&gt;redis.conf&lt;/code&gt; - the whole point is to force this boot to load &lt;code&gt;dump.rdb&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start the master and hold your breath:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;   redis-cli DBSIZE
   (integer) 290834
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Start the replicas (they full-resync from the restored master - you do &lt;em&gt;not&lt;/em&gt; restore the RDB onto replicas), then the sentinels, last.&lt;/li&gt;
&lt;li&gt;And only now, enable AOF &lt;strong&gt;the right way&lt;/strong&gt;, on the live instance:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   redis-cli CONFIG SET appendonly &lt;span class="nb"&gt;yes&lt;/span&gt;
   &lt;span class="c"&gt;# wait for: INFO persistence → aof_rewrite_in_progress:0&lt;/span&gt;
   &lt;span class="c"&gt;# verify:   appendonlydir/ base file is megabytes, not ~100 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;em&gt;then&lt;/em&gt; persist &lt;code&gt;appendonly yes&lt;/code&gt; into &lt;code&gt;redis.conf&lt;/code&gt;. Plus, finally, an immediate &lt;code&gt;scp&lt;/code&gt; of the recovered dump to another machine.&lt;/p&gt;

&lt;p&gt;290,834 keys - eight more than the older candidate, because the winning file was written a couple of minutes later in the timeline. Full recovery.&lt;/p&gt;

&lt;p&gt;One detail I still enjoy: the winning file wasn't the old &lt;code&gt;dump.rdb&lt;/code&gt; at all. It was an &lt;strong&gt;AOF base file&lt;/strong&gt; written &lt;em&gt;during&lt;/em&gt; the incident - the populated base from that brief window when everything still existed. In Redis 7 the AOF base is itself RDB-format, which is why we could rename it to &lt;code&gt;dump.rdb&lt;/code&gt; and boot straight from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable AOF on the running instance first.&lt;/strong&gt; &lt;code&gt;CONFIG SET appendonly yes&lt;/code&gt;, wait for the rewrite to finish, verify the AOF base has real size - &lt;em&gt;then&lt;/em&gt; edit the config file. Config-file-then-restart is the data-loss path, because Redis with AOF enabled ignores your RDB at boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"It worked in test" only counts if test rehearsed the same sequence.&lt;/strong&gt; My test-environment run succeeded &lt;em&gt;because&lt;/em&gt; I happened to run &lt;code&gt;CONFIG SET&lt;/code&gt; first there. Same change, different order of operations, opposite outcome. Rehearse the runbook, not the end state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy the data files before touching persistence settings.&lt;/strong&gt; A 30-second &lt;code&gt;cp dump.rdb dump.rdb.$(date +%F)&lt;/code&gt; (and ideally an &lt;code&gt;scp&lt;/code&gt; off-box) would have turned a five-hour incident into a five-minute one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify between steps of a rolling change.&lt;/strong&gt; I rebooted three nodes back to back and only checked &lt;code&gt;DBSIZE&lt;/code&gt; at the end. One check after the first node would have contained the blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replication is not backup.&lt;/strong&gt; The replicas didn't save the data - they synchronized its destruction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you do lose a file: stop writes immediately.&lt;/strong&gt; Deleted ≠ gone. &lt;code&gt;rename()&lt;/code&gt;-replaced files leave their blocks in free space, and &lt;code&gt;dd&lt;/code&gt; + a signature scan can get them back - but only until something reuses those blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loose RDB-only persistence (&lt;code&gt;save 3600 1&lt;/code&gt;, &lt;code&gt;appendonly no&lt;/code&gt;) is its own slow-motion incident.&lt;/strong&gt; Ours had been quietly able to roll back up to an hour of writes on every restart - that's what made a feature flag look haunted in the first place, which is the only reason we went looking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The irony of the whole story: the change that destroyed the database was the one meant to make it durable. The fix was correct; the &lt;em&gt;order&lt;/em&gt; was fatal. In operations, sequence is part of the change.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you ever had a "the fix caused the outage" incident? I'd love to hear about it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>redis</category>
      <category>postmortem</category>
      <category>devops</category>
    </item>
    <item>
      <title>A Design Flaw in Claude Code's Documentation Skill: One Question, 265k–355k Tokens</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Sun, 09 Aug 2026 16:34:33 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/a-design-flaw-in-claude-codes-documentation-skill-one-question-265k-355k-tokens-15g7</link>
      <guid>https://dev.to/vadim_albarov/a-design-flaw-in-claude-codes-documentation-skill-one-question-265k-355k-tokens-15g7</guid>
      <description>&lt;p&gt;I typed a one-line question into Claude Code - &lt;code&gt;does fable use api billing?&lt;/code&gt; - and then ran &lt;code&gt;/context&lt;/code&gt; out of habit.&lt;/p&gt;

&lt;p&gt;30% of a 1,000,000-token context window was gone. One question, one answer, 295k tokens used.&lt;/p&gt;

&lt;p&gt;Then I ran the same prompt on a second laptop: &lt;strong&gt;40%&lt;/strong&gt;. Same question, same answer, ~100k tokens more.&lt;/p&gt;

&lt;p&gt;Same CLI version on both machines. I'll call them &lt;strong&gt;laptop 1&lt;/strong&gt; (30%) and &lt;strong&gt;laptop 2&lt;/strong&gt; (40%). This is the story of finding those 100k tokens. Spoiler: every theory I had was wrong, and the root cause turned out to be a design flaw in a single bundled skill - one you can partially work around by &lt;em&gt;adding&lt;/em&gt; files to a folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline
&lt;/h2&gt;

&lt;p&gt;Here's what &lt;code&gt;/context&lt;/code&gt; showed on laptop 1 after that single exchange:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;System prompt&lt;/td&gt;
&lt;td&gt;5.3k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System tools&lt;/td&gt;
&lt;td&gt;23.7k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP tools (59 tools, deferred)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory files&lt;/td&gt;
&lt;td&gt;318&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills (16 skill descriptions)&lt;/td&gt;
&lt;td&gt;2.2k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Messages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;264.6k&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free space&lt;/td&gt;
&lt;td&gt;703.9k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jumped out.&lt;/p&gt;

&lt;p&gt;First, the fixed overhead everyone worries about - MCP servers, skills, memory - is nearly free. 59 MCP tools sat at &lt;strong&gt;0 tokens&lt;/strong&gt; because Claude Code defers their schemas until they're actually used. All 16 skill &lt;em&gt;descriptions&lt;/em&gt; together cost 2.2k tokens.&lt;/p&gt;

&lt;p&gt;Second, the &lt;strong&gt;Messages&lt;/strong&gt; category held 264.6k tokens after a one-line question. The conversation itself was maybe 2k tokens. The rest arrived because my question mentioned a Claude model name, which triggered the built-in &lt;code&gt;claude-api&lt;/code&gt; skill - and a skill trigger doesn't just load instructions. This one injected its entire documentation payload into the conversation as a single message.&lt;/p&gt;

&lt;p&gt;Worth pausing on that: my question was a billing lookup that a single Google search answers in five seconds. And before you conclude "well, agent sessions are just expensive" - they aren't. As a control, I asked two other lookup questions in fresh sessions: &lt;em&gt;"what's the latest Node LTS version?"&lt;/em&gt; and &lt;em&gt;"MIT vs Apache 2.0?"&lt;/em&gt;. Both together cost &lt;strong&gt;7.5k tokens&lt;/strong&gt;. Ordinary questions are cheap.&lt;/p&gt;

&lt;p&gt;The 265k burn has one specific trigger: mentioning a Claude model name. That summons the built-in &lt;code&gt;claude-api&lt;/code&gt; skill, which has no notion of question weight - a casual pricing question gets the exact same multi-hundred-KB documentation payload as "implement a streaming tool-use loop." I paid a quarter of a million tokens for what one web search would have told me.&lt;/p&gt;

&lt;p&gt;On laptop 2, that same category showed &lt;strong&gt;354k tokens&lt;/strong&gt;. The 90k-token mystery lived entirely inside one message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory #1: local CLAUDE.md files
&lt;/h2&gt;

&lt;p&gt;My first guess: laptop 2 has more project instruction files - &lt;code&gt;CLAUDE.md&lt;/code&gt;, memory, rules - quietly injected into context.&lt;/p&gt;

&lt;p&gt;Dead on arrival. Memory files accounted for 318 tokens on laptop 1, and the gap was ~90k tokens ≈ 360 KB of text. A CLAUDE.md would have to be a small book. More importantly, the gap persisted when I ran the prompt from an &lt;strong&gt;empty folder&lt;/strong&gt; on laptop 2 - no project files at all, still ~355k.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory #2: skill version
&lt;/h2&gt;

&lt;p&gt;Bundled skills live in a content-addressed cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="nv"&gt;%LOCALAPPDATA%&lt;/span&gt;\Temp\claude\bundled&lt;span class="na"&gt;-skills&lt;/span&gt;\&amp;lt;cli&lt;span class="na"&gt;-version&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;\&amp;lt;hash&amp;gt;\claude&lt;span class="na"&gt;-api&lt;/span&gt;\
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both machines ran CLI 2.1.226 with skill version 2.1.226. Same version... but &lt;strong&gt;different hashes&lt;/strong&gt;. Promising! Except when I compared the actual files, every doc folder was byte-identical - 847 KB of assets on both machines. The hash difference was real but, as it turned out later, a symptom rather than a cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory #3: the tokenizer
&lt;/h2&gt;

&lt;p&gt;This one almost fooled me, because the arithmetic was beautiful. I had tried Sonnet 5 at some point, and it ships a new tokenizer that produces roughly 30% more tokens for the same text.&lt;/p&gt;

&lt;p&gt;264.6k × 1.3 ≈ &lt;strong&gt;344k&lt;/strong&gt;. Almost exactly laptop 2's number. Same bytes, different ruler!&lt;/p&gt;

&lt;p&gt;Then I ran the control: same model, same effort, both machines. The gap survived. Tokenizer eliminated. (Keep this failure mode in mind though - token-budget intuitions genuinely don't transfer across model families.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Transcript forensics
&lt;/h2&gt;

&lt;p&gt;Same CLI, same skill assets, same model, same prompt - and different token counts. At this point the only honest move was to stop theorizing and diff the actual bytes.&lt;/p&gt;

&lt;p&gt;Claude Code writes every session to a JSONL transcript. Finding the heavy message takes one loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$proj&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;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="s2"&gt;\.claude\projects"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Directory&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;Sort-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;LastWriteTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Descending&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;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-First&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$t&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;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$proj&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FullName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*.&lt;/span&gt;&lt;span class="nf"&gt;jsonl&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;Sort-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;LastWriteTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Descending&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;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-First&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$i&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="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FullName&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;ForEach-Object&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="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-gt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="n"&gt;KB&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; &lt;/span&gt;&lt;span class="s2"&gt;"Line &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="n"&gt;/1KB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;) KB"&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Laptop 1 transcript: one message of &lt;strong&gt;719 KB&lt;/strong&gt;. Laptop 2 transcript: one message of &lt;strong&gt;957 KB&lt;/strong&gt;. There's the gap - 238 KB of text, ~90k tokens at ~2.6 characters per token.&lt;/p&gt;

&lt;p&gt;The payload is the skill's documentation, embedded as &lt;code&gt;&amp;lt;doc path="..."&amp;gt;&lt;/code&gt; blocks. Extracting the doc lists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;doc path=\\"([^\\"]+)\\"'&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="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;ForEach-Object&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="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Groups&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="nf"&gt;Value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laptop 1: 32 docs&lt;/strong&gt; - shared API docs + the &lt;code&gt;python/&lt;/code&gt; folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laptop 2: 65 docs&lt;/strong&gt; - shared API docs + &lt;strong&gt;all eight language folders&lt;/strong&gt;: Python, TypeScript, Go, Java, C#, PHP, Ruby, and cURL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All 32 docs the payloads had in common were byte-identical. The laptop 2 payload simply contained 33 extra language docs totaling 237 KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reveal
&lt;/h2&gt;

&lt;p&gt;Diffing the instruction text at the top of the two payloads (60 KB each, otherwise identical) surfaced exactly one difference:&lt;/p&gt;

&lt;p&gt;Laptop 1 payload:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;→ Refer to &lt;code&gt;python/claude-api/README.md&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Laptop 2 payload:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;→ Refer to &lt;code&gt;unknown/claude-api/README.md&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;No project language was auto-detected. Ask the user which language they are using, then refer to the matching docs below.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the whole mechanism. When the &lt;code&gt;claude-api&lt;/code&gt; skill triggers, Claude Code detects your project's language from the working directory and injects only that language's documentation. When it detects &lt;em&gt;nothing&lt;/em&gt; - an empty folder, a docs-only repo - the fallback is to inject &lt;strong&gt;documentation for every supported language&lt;/strong&gt;, because the model might need any of them.&lt;/p&gt;

&lt;p&gt;And why did laptop 1 detect Python? The folder I was in had a subdirectory containing a Python project with a &lt;code&gt;.venv&lt;/code&gt; - thousands of &lt;code&gt;.py&lt;/code&gt; files. A stray virtualenv saved me 90k tokens.&lt;/p&gt;

&lt;p&gt;The different cache hashes made sense now too: the skill bundle appears to be cached per &lt;em&gt;rendered variant&lt;/em&gt; - the assets are identical, but the instruction text differs by detection outcome, so each outcome gets its own hash directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The irony, and the side effect worth knowing
&lt;/h2&gt;

&lt;p&gt;Here's my favorite part. When I got serious about controlling variables, I ran the "clean" experiment: empty folder, fresh session, same prompt. That methodologically pure setup is precisely what &lt;strong&gt;maximizes&lt;/strong&gt; the payload. The controlled experiment created the condition it was measuring.&lt;/p&gt;

&lt;p&gt;The flip side is a genuinely useful, if odd, side effect: &lt;strong&gt;having language context in your working folder reduces token usage.&lt;/strong&gt; Any file that lets Claude Code detect a language - a &lt;code&gt;.py&lt;/code&gt; file, a &lt;code&gt;package.json&lt;/code&gt;, a &lt;code&gt;pyproject.toml&lt;/code&gt;, even a leftover &lt;code&gt;.venv&lt;/code&gt; - pins the skill payload to one language's docs and cuts ~90k tokens (about 25% of the payload) off every &lt;code&gt;claude-api&lt;/code&gt; skill trigger. The final scoreboard, reproduced on both machines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Working directory&lt;/th&gt;
&lt;th&gt;Docs injected&lt;/th&gt;
&lt;th&gt;Payload&lt;/th&gt;
&lt;th&gt;Messages after one question&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Any folder with language markers&lt;/td&gt;
&lt;td&gt;32 (shared + one language)&lt;/td&gt;
&lt;td&gt;719 KB&lt;/td&gt;
&lt;td&gt;~265k tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty / language-less folder&lt;/td&gt;
&lt;td&gt;65 (shared + all 8 languages)&lt;/td&gt;
&lt;td&gt;957 KB&lt;/td&gt;
&lt;td&gt;~355-360k tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So if you're about to ask Claude Code API questions from some scratch directory: don't. Run it from a real project - or drop a single &lt;code&gt;pyproject.toml&lt;/code&gt; (or the equivalent for your language) into the scratch folder first. It reads as a joke, but it's a measurable 90k-token difference per session, and on smaller context windows it's not funny at all: &lt;strong&gt;the all-languages payload alone wouldn't fit in a 200k-token context window.&lt;/strong&gt; This entire question is only answerable on a 1M-window model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a flaw, not a feature
&lt;/h2&gt;

&lt;p&gt;Skills in Claude Code are designed around &lt;em&gt;progressive disclosure&lt;/em&gt;: a one-line description sits in context (all 16 bundled skills together cost 2.2k tokens), and the full instructions load only when triggered. The &lt;code&gt;claude-api&lt;/code&gt; skill follows that pattern for its trigger - and then abandons it entirely for its content: instead of letting the model read the docs it needs on demand, it eagerly injects the whole documentation set as a single message. It's the only bundled skill big enough to need a disk cache at all (847 KB; the other 15 are trivially small).&lt;/p&gt;

&lt;p&gt;The no-language fallback makes it worse in a way that's almost comic. The injected instruction text literally says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No project language was auto-detected. &lt;strong&gt;Ask the user which language they are using&lt;/strong&gt;, then refer to the matching docs below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;...but by the time the model can ask, all eight languages' documentation has already been paid for. The question comes after the purchase. Any of the obvious designs - ask first and inject one language; inject the shared docs and let the model read language files on demand; scale the payload to the question - would cap the cost at the detected-language level or below.&lt;/p&gt;

&lt;p&gt;To be fair about scope: this is one skill, in one CLI version (2.1.226), and the payload is genuinely useful when you're writing code against the Claude API in a detected-language project. The flaw is the eager all-languages fallback and the trigger's insensitivity to question weight - both fixable upstream without losing what the skill is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Skill payloads dominate context cost.&lt;/strong&gt; Everything people usually blame - MCP servers, memory files, system prompts - added up to ~31k tokens for me. One skill trigger added 265-360k. Audit accordingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The flaw is specific, not general agent overhead.&lt;/strong&gt; Control questions in fresh sessions ("latest Node LTS?", "MIT vs Apache 2.0?") cost 7.5k tokens combined. The expensive trigger is mentioning a Claude model or API name. For quick Claude pricing/docs lookups, use a web search - inside Claude Code, the skill &lt;em&gt;will&lt;/em&gt; fire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deferred loading works.&lt;/strong&gt; 59 MCP tools at 0 tokens until used. If your setup loads MCP schemas eagerly, that's worth fixing, but it wasn't my problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/context&lt;/code&gt; tells you the category; the transcript tells you the culprit.&lt;/strong&gt; The JSONL line-length trick above takes 30 seconds and points at the exact message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same CLI version ≠ same context cost.&lt;/strong&gt; The cost depends on runtime conditions - in this case, what's sitting in your working directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language markers in the folder are a token optimization.&lt;/strong&gt; Unintuitive, but reproducible: give the language detector something to find, and the skill injects one language's docs instead of eight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token intuitions don't transfer across model families.&lt;/strong&gt; My tokenizer theory was wrong &lt;em&gt;this time&lt;/em&gt;, but the ~30% Sonnet 5 difference is real - re-baseline when you switch models.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything above was measured on Claude Code 2.1.226 (bundled &lt;code&gt;claude-api&lt;/code&gt; skill 2.1.226) with Fable 5 and Sonnet 5, on two Windows machines. The behavior may well change in future releases - arguably the fallback should ask &lt;em&gt;before&lt;/em&gt; injecting 237 KB of polyglot documentation - but the audit method will keep working regardless.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reproduce it yourself: ask Claude Code any Claude-API question from an empty folder, run &lt;code&gt;/context&lt;/code&gt;, then do the same from inside a Python or TypeScript repo and compare the Messages category.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;UPD: The 2.1.234 update fixed the issue (at least for me). Now the claude‑api skill is no longer bloated and does not exhaust your limits anymore&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>llm</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I fine-tuned a 1.7B model on data that doesn't exist - a 17-minute recipe</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Thu, 06 Aug 2026 20:34:02 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/i-fine-tuned-a-17b-model-on-data-that-doesnt-exist-a-17-minute-recipe-3850</link>
      <guid>https://dev.to/vadim_albarov/i-fine-tuned-a-17b-model-on-data-that-doesnt-exist-a-17-minute-recipe-3850</guid>
      <description>&lt;p&gt;&lt;em&gt;The full methodology behind localscrub's stage-2 specialist: synthetic&lt;br&gt;
training data with exact labels, training on the serving distribution, and&lt;br&gt;
why the fine-tune's first product is not accuracy - it's parseability.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;In &lt;a href="https://dev.to/vadim_albarov/a-125m-model-beat-a-14b-llm-at-de-identifying-medical-text-40x-faster-on-cpu-201a"&gt;the last article&lt;/a&gt; I benchmarked &lt;a href="https://github.com/valbarov/localscrub" rel="noopener noreferrer"&gt;localscrub&lt;/a&gt;, a local-first PHI de-identification cascade, and buried a teaser near the end:&lt;br&gt;
a 1.7-billion-parameter model, LoRA-tuned in 17 minutes on a consumer&lt;br&gt;
laptop, went from producing &lt;em&gt;zero&lt;/em&gt; parseable replies to 0.92 redaction&lt;br&gt;
recall on authentic clinical prose. This article is the recipe - every&lt;br&gt;
command, every hyperparameter, and the three methodology decisions that I&lt;br&gt;
think matter more than any of the numbers.&lt;/p&gt;

&lt;p&gt;The de-identification task is the case study, but the recipe generalizes to&lt;br&gt;
any structured-extraction job you want a small local model to do: if you&lt;br&gt;
can generate your training data and you train on the exact prompt your&lt;br&gt;
inference code sends, a specialist you can retrain from scratch in under&lt;br&gt;
half an hour is within reach on one 16 GB GPU.&lt;/p&gt;

&lt;p&gt;One constraint shaped everything: &lt;strong&gt;no real patient data anywhere in the&lt;br&gt;
loop.&lt;/strong&gt; Not in the training set, not in the eval set, not in a single&lt;br&gt;
prompt sent to any model at any step. By the end you'll see that this&lt;br&gt;
constraint wasn't a handicap - the synthetic pipeline it forced turned out&lt;br&gt;
to be the most valuable asset in the project.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why fine-tune at all, when a pretrained classifier already wins?
&lt;/h2&gt;

&lt;p&gt;Fair question, because the last article's headline was an off-the-shelf&lt;br&gt;
125M token classifier hitting 0.999 redaction recall. If that's available,&lt;br&gt;
why train anything?&lt;/p&gt;

&lt;p&gt;Because a token classifier cannot take stage 2's seat. localscrub's second&lt;br&gt;
stage has a conversational contract: it receives the note plus &lt;em&gt;escalation&lt;br&gt;
hints&lt;/em&gt; - ambiguous spans that the rules engine flagged but couldn't resolve&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;and returns verbatim snippets with types, as JSON, adjudicating the
escalations along the way. A token classifier tags a fixed label set; ask
it about an identifier type it wasn't trained on and it has no opinion, and
it can adjudicate "is this flagged span actually PHI?" only for the types
it already knows. Only an instruction-following model can hold up the whole
contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 14B generalist (qwen3:14b via Ollama) holds it up at 5-8 seconds per&lt;br&gt;
note. The question worth 17 minutes of GPU time: can a model an eighth that&lt;br&gt;
size be &lt;em&gt;taught&lt;/em&gt; to?&lt;/p&gt;
&lt;h2&gt;
  
  
  The training data costs nothing and leaks nothing
&lt;/h2&gt;

&lt;p&gt;The usual fine-tuning bottleneck is labeled data. Clinical de-id makes it&lt;br&gt;
worse: the gold-standard corpus (i2b2/n2c2 2014) sits behind a data use&lt;br&gt;
agreement, and real notes are radioactive - one mishandled training example&lt;br&gt;
and your privacy tool has a privacy incident.&lt;/p&gt;

&lt;p&gt;localscrub sidesteps both because its synthetic corpus generator already&lt;br&gt;
existed for evaluation. &lt;code&gt;localscrub synth&lt;/code&gt; renders clinical notes from&lt;br&gt;
templates with fabricated identifiers planted at recorded character&lt;br&gt;
offsets - every phone from the reserved 555-01XX block, every domain from&lt;br&gt;
RFC 2606, every credit card Luhn-valid on a test prefix - deterministic&lt;br&gt;
from a seed. The eval harness scores against those exact gold spans.&lt;/p&gt;

&lt;p&gt;The insight that unlocks fine-tuning: &lt;strong&gt;a corpus with perfect gold spans is&lt;br&gt;
also a perfect SFT dataset.&lt;/strong&gt; One command emits training pairs instead of&lt;br&gt;
eval records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;localscrub sft &lt;span class="nt"&gt;-n&lt;/span&gt; 2000 &lt;span class="nt"&gt;--seed&lt;/span&gt; 7 &lt;span class="nt"&gt;-o&lt;/span&gt; data/corpora/sft-2000.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two thousand examples, generated in seconds, with two properties paid&lt;br&gt;
datasets can't match:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero annotation noise.&lt;/strong&gt; The labels aren't human annotations of
generated text - the generator &lt;em&gt;planted&lt;/em&gt; the identifiers, so the gold is
exact by construction. No inter-annotator disagreement, no boundary
fuzziness, no label budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero exposure.&lt;/strong&gt; There is no real PHI to leak because there is no real
PHI. The training set is as regenerable and disposable as the eval set -
corpora are gitignored, adapters are gitignored, and everything rebuilds
from a seed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seed discipline matters here: training uses seed 7, evaluation uses seed&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Disjoint seeds mean no example overlap - but I'll flag the honest
limitation now rather than in the fine print: the template corpus has only
seven note skeletons, so train and eval share &lt;em&gt;structure&lt;/em&gt; even though they
share no &lt;em&gt;content&lt;/em&gt;. Every template-corpus "after" number below is
optimistic by construction. The MTSamples benchmark - authentic medical
transcription prose that never entered the fine-tune in any form - is the
honest generalization test, and it's the one I'll ask you to judge the
recipe by.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The one rule: train on the serving distribution
&lt;/h2&gt;

&lt;p&gt;Here is the decision I'd defend hardest, and the one I see skipped most&lt;br&gt;
often in fine-tuning write-ups: &lt;strong&gt;each training prompt is built by the&lt;br&gt;
actual inference code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;localscrub sft&lt;/code&gt; doesn't format notes into some training-time template that&lt;br&gt;
resembles what inference does. For every example, it &lt;em&gt;runs stage 1 on the&lt;br&gt;
note&lt;/em&gt; - the real rules engine, producing real escalation hints - and then&lt;br&gt;
calls &lt;code&gt;localscrub.stage2.extraction_prompt&lt;/code&gt;, the same function the serving&lt;br&gt;
path calls, to build the prompt. The completion is the extractor's exact&lt;br&gt;
reply format, and it parses with the same salvage-tolerant parser&lt;br&gt;
inference uses.&lt;/p&gt;

&lt;p&gt;The tuned model never sees a prompt shape at inference time that it didn't&lt;br&gt;
see thousands of times in training, escalation hints included. Most&lt;br&gt;
"fine-tuned model underperforms in production" stories I've read trace back&lt;br&gt;
to exactly this seam: the training data was formatted by a script that&lt;br&gt;
&lt;em&gt;approximated&lt;/em&gt; the serving prompt, and the approximation drifted.&lt;br&gt;
Eliminating the seam costs nothing - reuse the inference code - and it's&lt;br&gt;
free insurance.&lt;/p&gt;

&lt;p&gt;Two corollaries of the same principle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw prompt→completion, no chat template.&lt;/strong&gt; The serving path sends plain
text, so training does too. This isn't just simplicity - format
discipline is part of what the before/after numbers measure, and wrapping
everything in a chat template would train a different behavior than the
one being served and scored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completion-only loss.&lt;/strong&gt; The model is graded on its reply, not on its
ability to predict the note back. Standard practice, but it composes with
the above: the loss covers exactly the tokens the serving path will
consume.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The recipe
&lt;/h2&gt;

&lt;p&gt;Hardware: one RTX 5080 Laptop GPU, 16 GB. Model: Qwen3-1.7B-Base&lt;br&gt;
(Apache-2.0). Total wall-clock for training: &lt;strong&gt;17 minutes.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. training data: 2000 examples, seed disjoint from eval seeds&lt;/span&gt;
uv run localscrub sft &lt;span class="nt"&gt;-n&lt;/span&gt; 2000 &lt;span class="nt"&gt;--seed&lt;/span&gt; 7 &lt;span class="nt"&gt;-o&lt;/span&gt; data/corpora/sft-2000.jsonl

&lt;span class="c"&gt;# 2. train (bf16 LoRA, ~17-30 min on 16 GB)&lt;/span&gt;
uv run &lt;span class="nt"&gt;--group&lt;/span&gt; train python scripts/finetune_lora.py &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--data&lt;/span&gt; data/corpora/sft-2000.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--out&lt;/span&gt; models/stage2-qwen3-1.7b-lora

&lt;span class="c"&gt;# 3. the authentic-prose benchmark (public MTSamples CSV, synthetic injections)&lt;/span&gt;
uv run localscrub mtsamples &lt;span class="nt"&gt;--fetch&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-o&lt;/span&gt; data/corpora/mtsamples-100.jsonl

&lt;span class="c"&gt;# 4. before/after as stage 2, standard eval harness&lt;/span&gt;
uv run &lt;span class="nt"&gt;--group&lt;/span&gt; train python scripts/eval_lora.py &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--template-notes&lt;/span&gt; 20 &lt;span class="nt"&gt;--mts-notes&lt;/span&gt; 15 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--mtsamples&lt;/span&gt; data/corpora/mtsamples-100.jsonl          &lt;span class="c"&gt;# before (base) - fewer&lt;/span&gt;
                                                          &lt;span class="c"&gt;# notes: ~30 s each&lt;/span&gt;
uv run &lt;span class="nt"&gt;--group&lt;/span&gt; train python scripts/eval_lora.py &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--adapter&lt;/span&gt; models/stage2-qwen3-1.7b-lora &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--mtsamples&lt;/span&gt; data/corpora/mtsamples-100.jsonl          &lt;span class="c"&gt;# after (50 + 30 notes)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hyperparameters, all overridable via flags: LoRA r=16, α=32, dropout 0.05,&lt;br&gt;
all-linear targets; learning rate 1e-4 with cosine schedule and 3% warmup;&lt;br&gt;
2 epochs; effective batch 16 (batch 2 × gradient accumulation 8); max&lt;br&gt;
length 2048; bf16; completion-only loss; seed 7.&lt;/p&gt;

&lt;p&gt;Nothing exotic - r=16 all-linear LoRA at lr 1e-4 is close to community&lt;br&gt;
defaults for this model size, and that's deliberate. The recipe's leverage&lt;br&gt;
is in the data (exact labels, serving distribution), not in hyperparameter&lt;br&gt;
heroics. If your first instinct on a weak result is to sweep learning&lt;br&gt;
rates, look at your data pipeline first.&lt;/p&gt;

&lt;p&gt;Deployment note: to serve the adapter through Ollama, convert it with&lt;br&gt;
llama.cpp's &lt;code&gt;convert_lora_to_gguf.py&lt;/code&gt; and reference it from a &lt;code&gt;Modelfile&lt;/code&gt;&lt;br&gt;
&lt;code&gt;ADAPTER&lt;/code&gt; line. The evaluation script talks to the model via HF&lt;br&gt;
&lt;code&gt;transformers&lt;/code&gt; directly, which skips that step for the before/after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the before/after: parseability first, accuracy second
&lt;/h2&gt;

&lt;p&gt;The numbers, scored by the same harness as every other configuration in&lt;br&gt;
the project. &lt;strong&gt;Redaction recall&lt;/strong&gt; is the safety metric - the fraction of&lt;br&gt;
gold spans whose every character is removed from the output; a half-redacted&lt;br&gt;
address counts as a miss, because a half-redacted address is still a leak.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;config&lt;/th&gt;
&lt;th&gt;corpus&lt;/th&gt;
&lt;th&gt;unparseable replies&lt;/th&gt;
&lt;th&gt;redaction recall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;base 1.7B (before)&lt;/td&gt;
&lt;td&gt;template, 20 notes&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;0.66 (= stage 1 alone)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;base 1.7B (before)&lt;/td&gt;
&lt;td&gt;MTSamples, 15 notes&lt;/td&gt;
&lt;td&gt;15/15&lt;/td&gt;
&lt;td&gt;0.61 (= stage 1 alone)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ LoRA (after)&lt;/td&gt;
&lt;td&gt;template, 50 notes&lt;/td&gt;
&lt;td&gt;0/50&lt;/td&gt;
&lt;td&gt;1.000*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ LoRA (after)&lt;/td&gt;
&lt;td&gt;MTSamples, 30 notes&lt;/td&gt;
&lt;td&gt;0/30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.924&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* optimistic by construction - train and eval share note skeletons, as&lt;br&gt;
conceded above. The MTSamples row is the one to trust.&lt;/p&gt;

&lt;p&gt;Look at the "before" rows first, because they're the finding I didn't&lt;br&gt;
expect. The base model didn't merely underperform - it produced unparseable&lt;br&gt;
output on &lt;strong&gt;every single note&lt;/strong&gt;, 35 of 35. Rambling, format drift, never&lt;br&gt;
once a reply the parser could consume. Since localscrub's merge is&lt;br&gt;
fail-closed, a useless stage 2 degrades the cascade to exactly the stage-1&lt;br&gt;
baseline - the 0.66 and 0.61 aren't the small model helping a little,&lt;br&gt;
they're the cascade running as if stage 2 weren't there.&lt;/p&gt;

&lt;p&gt;After 17 minutes of LoRA: zero unparseable replies across 80 notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine-tune's first product is not accuracy - it's parseability.&lt;/strong&gt;&lt;br&gt;
That transition alone converts a dead stage 2 into a working one, before&lt;br&gt;
any detection improvements register. Accuracy followed: 0.61 → 0.92&lt;br&gt;
redaction recall on prose the fine-tune never saw. If you're evaluating&lt;br&gt;
whether a small model can replace a big one in a structured pipeline, check&lt;br&gt;
format compliance before you check task skill - in my case it was the&lt;br&gt;
entire difference between "useless" and "viable," and it's the cheapest&lt;br&gt;
thing fine-tuning buys.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the remaining gap is made of - and how I know
&lt;/h2&gt;

&lt;p&gt;On MTSamples, the 17-minute specialist scores 0.924. The 14B generalist&lt;br&gt;
scores 0.976 in the same seat on the same notes (0.967 over the full&lt;br&gt;
100-note benchmark); the pretrained NER extra reaches 0.999. So&lt;br&gt;
the specialist does not yet win on unseen prose - and it's worth being&lt;br&gt;
precise about &lt;em&gt;why&lt;/em&gt;, because the two candidate explanations point at&lt;br&gt;
opposite fixes.&lt;/p&gt;

&lt;p&gt;If the gap were &lt;strong&gt;capacity&lt;/strong&gt; - 1.7B just isn't enough model - the fix is a&lt;br&gt;
bigger model, and the 17-minute story dies. If the gap is &lt;strong&gt;training-data&lt;br&gt;
diversity&lt;/strong&gt; - 2,000 examples from 7 note skeletons teach the task format&lt;br&gt;
perfectly but narrative prose only partly - the fix is better data, and the&lt;br&gt;
whole pipeline stays consumer-hardware-sized.&lt;/p&gt;

&lt;p&gt;The evidence leans diversity. The tuned model scores a &lt;em&gt;perfect&lt;/em&gt; 1.000&lt;br&gt;
in-distribution while dropping to 0.92 out-of-distribution - the signature&lt;br&gt;
of a distribution gap, not of a model too small for the task it was&lt;br&gt;
trained on. It isn't proof: a 1.7B model can ace seven memorized skeletons&lt;br&gt;
and still lack the parameters for open narrative prose. But diversity is&lt;br&gt;
the cheaper hypothesis, and - unlike capacity - this pipeline can test it&lt;br&gt;
without buying anything.&lt;/p&gt;

&lt;p&gt;Which makes the fix mechanical rather than speculative, and the pipeline&lt;br&gt;
already contains both halves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mix in MTSamples-injected notes&lt;/strong&gt; (&lt;code&gt;localscrub mtsamples&lt;/code&gt;) from
transcripts disjoint with the eval set - authentic narrative rhythm
around synthetic gold spans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mix in &lt;code&gt;--diversify&lt;/code&gt; paraphrases.&lt;/strong&gt; This is the corpus generator's
trick for letting a local LLM rewrite template prose &lt;em&gt;without ever seeing
an identifier&lt;/em&gt;: every gold span is masked behind a sentinel token
(&lt;code&gt;[[E3]]&lt;/code&gt;), the model paraphrases around the sentinels, values are
re-substituted and offsets recomputed. A rewrite is rejected if any
sentinel is dropped or duplicated, or if re-running stage 1 on the
rebuilt text finds identifier-shaped strings outside the gold spans -
i.e., the model invented PHI. Labels stay exact by construction; only the
prose diversifies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same generator, same seeds, same 17-minute training loop - just a corpus&lt;br&gt;
with more imagination. That's the deeper point of the whole exercise:&lt;br&gt;
&lt;strong&gt;the synthetic corpus is the asset.&lt;/strong&gt; Data, training, and eval all&lt;br&gt;
regenerate from seeds; the model is almost a by-product. When the training&lt;br&gt;
data is a function rather than a file, "collect more data" becomes a code&lt;br&gt;
change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational potholes (the section I wish every recipe had)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An untuned model with no token cap looks exactly like a frozen process.&lt;/strong&gt;&lt;br&gt;
The first "before" eval appeared to hang. It hadn't - the base model was&lt;br&gt;
rambling toward a 4,096-token ceiling on every note, minutes per note, with&lt;br&gt;
nothing printed. Two fixes, both boring, both essential: a hard token cap&lt;br&gt;
on generation, and per-note progress output. Instrument your "before" run&lt;br&gt;
as carefully as your "after" - it's the row of the table nobody expects to&lt;br&gt;
debug, and the model most likely to misbehave is sitting in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eval latency is not serving latency.&lt;/strong&gt; The ~17-20 s/note these runs&lt;br&gt;
clock is naive &lt;code&gt;transformers.generate&lt;/code&gt; - no batching, no serving&lt;br&gt;
engine. Serve the adapter through Ollama or vLLM before quoting latency&lt;br&gt;
anywhere it matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blackwell GPUs need cu13x torch builds&lt;/strong&gt;, and uv wouldn't swap the&lt;br&gt;
default CPU torch for the CUDA build without &lt;code&gt;--reinstall&lt;/code&gt; and an explicit&lt;br&gt;
pin: &lt;code&gt;uv pip install --reinstall "torch==2.13.0+cu130" --index-url&lt;br&gt;
https://download.pytorch.org/whl/cu130&lt;/code&gt;. The training dependency group&lt;br&gt;
(peft, trl, datasets, accelerate) is deliberately not part of any install&lt;br&gt;
extra - trainers opt in, users never download it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adapters land in a gitignored &lt;code&gt;models/&lt;/code&gt; directory.&lt;/strong&gt; Weights never enter&lt;br&gt;
the repo - and after the last article's gitignore-trailing-comment incident&lt;br&gt;
silently un-ignored a 17 MB dataset, I now test what gitignore actually&lt;br&gt;
ignores rather than trusting what it appears to say.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats, stated rather than buried
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The template-corpus 1.000 is optimistic by construction (shared note
skeletons); judge the recipe by the MTSamples 0.924.&lt;/li&gt;
&lt;li&gt;MTSamples injections are synthetic identifiers in authentic prose, not
naturally occurring PHI; i2b2-style evaluation is pending a data use
agreement.&lt;/li&gt;
&lt;li&gt;MTSamples is a well-known public corpus, so the &lt;em&gt;transcripts&lt;/em&gt; may well
sit in the base model's pretraining data - "unseen" means unseen by the
fine-tune. The injected identifiers are freshly generated either way.&lt;/li&gt;
&lt;li&gt;0.924 does not beat the 14B generalist (0.976 on the same notes) or the
pretrained NER extra (0.999). The claim is not "small model wins" - it's that format
discipline plus most of the accuracy is purchasable for 17 minutes of
consumer GPU time and zero real records, with a documented path for the
rest.&lt;/li&gt;
&lt;li&gt;No real PHI was used anywhere in this work - training, eval, or any
prompt sent to any model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check my math
&lt;/h2&gt;

&lt;p&gt;The entire loop - corpus, training pairs, fine-tune, before/after eval -&lt;br&gt;
regenerates from the seeds in the commands above on one 16 GB GPU: seconds&lt;br&gt;
for the data, 17 minutes for the training, and roughly 40 more minutes of&lt;br&gt;
naive generation for the two evals - call it an hour end to end. The code is on &lt;a href="https://github.com/valbarov/localscrub" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, the package is on &lt;a href="https://pypi.org/project/localscrub/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;, and the recipe lives in&lt;br&gt;
&lt;a href="https://github.com/valbarov/localscrub/blob/main/docs/finetune.md" rel="noopener noreferrer"&gt;docs/finetune.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you try the recipe on your own extraction task - de-id or otherwise -&lt;br&gt;
I'd genuinely like to hear where it breaks. The next article sends the&lt;br&gt;
benchmark to the cloud de-id APIs, and that trust-boundary crossing gets a&lt;br&gt;
write-up of its own.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>A 125M model beat a 14B LLM at de-identifying medical text 40 faster, on CPU</title>
      <dc:creator>vadim albarov</dc:creator>
      <pubDate>Sun, 02 Aug 2026 04:13:27 +0000</pubDate>
      <link>https://dev.to/vadim_albarov/a-125m-model-beat-a-14b-llm-at-de-identifying-medical-text-40x-faster-on-cpu-201a</link>
      <guid>https://dev.to/vadim_albarov/a-125m-model-beat-a-14b-llm-at-de-identifying-medical-text-40x-faster-on-cpu-201a</guid>
      <description>&lt;h2&gt;
  
  
  Your data never leaves the machine - and you can check my math
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Building localscrub, a local-first PHI de-identification cascade, and&lt;br&gt;
benchmarking it honestly against the standard baseline - on one consumer&lt;br&gt;
laptop, with zero real patient data.&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;De-identifying clinical text today forces a bad trade. Cloud de-id APIs are&lt;br&gt;
accurate, but you send the sensitive data out in order to scrub it - the&lt;br&gt;
text crosses your trust boundary before a single character is redacted.&lt;br&gt;
Local rule-based tools keep the data home, but miss exactly the PHI that&lt;br&gt;
matters most: the context-dependent kind. A regex will catch an SSN every&lt;br&gt;
time; it will never catch "the patient's sister works at the bakery on Elm&lt;br&gt;
Street."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/valbarov/localscrub" rel="noopener noreferrer"&gt;localscrub&lt;/a&gt; is my attempt to&lt;br&gt;
refuse the trade. It runs a two-stage cascade entirely on your hardware: a&lt;br&gt;
fast rules-and-NER pass for the well-formatted identifiers - phones, emails,&lt;br&gt;
dates, account numbers - and a local LLM, served by Ollama with no network&lt;br&gt;
egress, for the ambiguous remainder. (How much each stage carries is an&lt;br&gt;
empirical question; the benchmark below answers it rather than assuming.)&lt;br&gt;
It's on PyPI as v0.1 (&lt;code&gt;pip install localscrub&lt;/code&gt;), and every number in this&lt;br&gt;
article reproduces from seeds on a single RTX 5080 laptop.&lt;/p&gt;

&lt;p&gt;This is the story of building it - and more importantly, of &lt;em&gt;measuring&lt;/em&gt; it,&lt;br&gt;
because a privacy tool with unverifiable accuracy claims is just a liability&lt;br&gt;
with a nice README. Along the way: a test set that is a function rather than&lt;br&gt;
a file, a 125-million-parameter model that beat a 14-billion-parameter one,&lt;br&gt;
an eval harness that indicted its own gold standard, and one cursed note&lt;br&gt;
that killed a three-hour benchmark at 99% complete.&lt;/p&gt;
&lt;h2&gt;
  
  
  The ground truth problem: a test set that is a function, not a file
&lt;/h2&gt;

&lt;p&gt;You cannot measure a de-identifier without ground truth, and I refused to&lt;br&gt;
use real PHI to get it. The gold-standard clinical de-id corpus (i2b2/n2c2&lt;br&gt;
2014) sits behind a data use agreement, and scraping a third-party re-upload&lt;br&gt;
would make a privacy project sloppy about data provenance on day one.&lt;/p&gt;

&lt;p&gt;So the corpus is generated. &lt;code&gt;localscrub synth&lt;/code&gt; renders synthetic clinical&lt;br&gt;
notes from templates - seven variants across six note types - with&lt;br&gt;
fabricated identifiers planted at recorded character offsets. Every phone&lt;br&gt;
number is from the reserved 555-01XX block, every domain from RFC 2606,&lt;br&gt;
every IP from RFC 5737, every credit card Luhn-valid on a test prefix. The&lt;br&gt;
output is JSONL with exact gold spans, deterministic from a seed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;localscrub synth &lt;span class="nt"&gt;-n&lt;/span&gt; 500 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;-o&lt;/span&gt; eval.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That determinism buys something subtle: an eval number becomes a property&lt;br&gt;
of the &lt;em&gt;code&lt;/em&gt;, not of a dataset file. Corpora are gitignored and&lt;br&gt;
regenerated at will. The test set is a function, not a file.&lt;/p&gt;

&lt;p&gt;Template-generated text invites an obvious objection: a detector could&lt;br&gt;
memorize the templates. The answer is &lt;code&gt;--diversify&lt;/code&gt;, which lets a local LLM&lt;br&gt;
paraphrase the connective prose &lt;em&gt;without ever seeing an identifier&lt;/em&gt;: every&lt;br&gt;
gold span is masked behind a sentinel token (&lt;code&gt;[[E3]]&lt;/code&gt;), the model rewrites&lt;br&gt;
around the sentinels, values are re-substituted, offsets recomputed. A&lt;br&gt;
rewrite is rejected if any sentinel is dropped or duplicated - or if&lt;br&gt;
re-running stage 1 on the rebuilt text finds identifier-shaped strings&lt;br&gt;
outside the gold spans, i.e. the model &lt;em&gt;invented&lt;/em&gt; PHI. The validation loop&lt;br&gt;
cost about ten lines and closes the biggest ground-truth-corruption risk.&lt;br&gt;
That is how you let an LLM touch your test set without trusting it.&lt;/p&gt;
&lt;h2&gt;
  
  
  An eval whose first job is to embarrass you precisely
&lt;/h2&gt;

&lt;p&gt;The harness (&lt;code&gt;localscrub eval&lt;/code&gt;) reports three numbers per entity type, and&lt;br&gt;
keeping them separate turned out to matter more than any single one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;relaxed&lt;/strong&gt; (type + character overlap): &lt;em&gt;did you find it?&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;strict&lt;/strong&gt; (type + exact boundaries): &lt;em&gt;are your boundaries calibrated?&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redaction recall&lt;/strong&gt; - the fraction of gold spans whose every character
is removed from the output, by any means, under any label: &lt;em&gt;is the output
actually safe?&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Redaction recall is the safety metric, and it is deliberately unforgiving:&lt;br&gt;
a detection that leaves half an address in the text does not count. Partial&lt;br&gt;
redaction of an address is still a leak.&lt;/p&gt;

&lt;p&gt;The three-metric split earned its keep on the very first run. Stage 1's URL&lt;br&gt;
recognizer scored relaxed 1.00 and strict &lt;strong&gt;0.00&lt;/strong&gt; - it was swallowing&lt;br&gt;
sentence-final periods on every single URL. Overlap-only scoring would never&lt;br&gt;
have surfaced it. The same first run put honest zeros on the board: NAME&lt;br&gt;
0.00, GEO 0.00, because stage 1 has no name recognizer by design. Overall&lt;br&gt;
redaction recall: 0.62. That 0.62 turned "stage 2 is on the roadmap" into a&lt;br&gt;
quantified gap - 38% of gold spans unprotected without it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cascade, and the one design decision that carried the project
&lt;/h2&gt;

&lt;p&gt;Stage 2 asks a local model (qwen3:14b via Ollama) to extract&lt;br&gt;
context-dependent PHI. The contract is the highest-leverage decision in the&lt;br&gt;
codebase: the model returns &lt;strong&gt;verbatim snippets plus a type - never&lt;br&gt;
character offsets&lt;/strong&gt;. LLMs cannot count characters, but they copy substrings&lt;br&gt;
reliably. Every returned snippet is located in the source text by string&lt;br&gt;
search, which redacts repeated mentions for free, and a hallucinated span&lt;br&gt;
dies at a &lt;code&gt;str.find&lt;/code&gt; instead of corrupting a redaction. Ask the model for&lt;br&gt;
what it can do (copy), verify what it can't (locate).&lt;/p&gt;

&lt;p&gt;The merge with stage 1 is additive and fail-closed. Stage-1 detections win&lt;br&gt;
overlaps - rules are better calibrated where rules apply. Ambiguous spans&lt;br&gt;
stage 1 flagged are put to the model for adjudication, but only in one&lt;br&gt;
direction: an escalation the model confirms is resolved; one it stays&lt;br&gt;
silent on remains escalated and gets redacted anyway. A 14B model's "no"&lt;br&gt;
never unredacts anything.&lt;/p&gt;

&lt;p&gt;First contact, 50 notes: redaction recall 0.62 → 0.89, NAME relaxed recall&lt;br&gt;
0.00 → 0.98. And one open wound: the model found "Cedar Vale" but clipped&lt;br&gt;
"4050 Mossbank Blvd", fully covering only 12% of address spans. Relaxed F1&lt;br&gt;
made GEO look twice as healthy as it was; redaction recall told the truth.&lt;/p&gt;
&lt;h2&gt;
  
  
  A 125M model beat the 14B model - 40× faster, on CPU
&lt;/h2&gt;

&lt;p&gt;Before reaching for fine-tuning, I tried the boring thing: an off-the-shelf&lt;br&gt;
de-id-specific token classifier (&lt;code&gt;obi/deid_roberta_i2b2&lt;/code&gt;, 125M parameters,&lt;br&gt;
trained on the i2b2 2014 corpus) wrapped as an optional stage-1 recognizer&lt;br&gt;
(&lt;code&gt;pip install 'localscrub[ner]'&lt;/code&gt;). Only its name and location labels are&lt;br&gt;
mapped; dates, phones, and emails stay with the regexes, whose boundaries&lt;br&gt;
are already exact.&lt;/p&gt;

&lt;p&gt;On the template corpus it was decisive: redaction recall 0.94 at 184 ms&lt;br&gt;
per note on CPU - matching the 14B model at the categories it was trained&lt;br&gt;
for, roughly forty times faster, no GPU.&lt;/p&gt;

&lt;p&gt;Let me concede the framing objection before anyone raises it: a specialist&lt;br&gt;
trained on exactly this task beating a prompted generalist is expected, not&lt;br&gt;
shocking. The finding is the &lt;em&gt;size of the trade&lt;/em&gt; - equal recall at&lt;br&gt;
one-fortieth the latency, no GPU - and it matters because the default&lt;br&gt;
recipe today is "throw an LLM at it," and for structured text the default&lt;br&gt;
is measurably wrong. Nor was the 14B handicapped: it ran qwen3:14b at&lt;br&gt;
temperature 0 with schema-constrained decoding and the same&lt;br&gt;
escalation-hint prompt that inference uses (&lt;a href="https://github.com/valbarov/localscrub/blob/main/src/localscrub/stage2.py" rel="noopener noreferrer"&gt;&lt;code&gt;extraction_prompt&lt;/code&gt;&lt;/a&gt; in the repo).&lt;/p&gt;

&lt;p&gt;The two models fail &lt;em&gt;differently&lt;/em&gt;,&lt;br&gt;
and that mattered later: the LLM copies name boundaries nearly perfectly&lt;br&gt;
but clips addresses; the token classifier covers whole addresses but drags&lt;br&gt;
titles and credentials into name spans. Complementary failure modes,&lt;br&gt;
measurable as such.&lt;/p&gt;

&lt;p&gt;Integrating it produced the best debugging afternoon of the project - three&lt;br&gt;
real bugs and a gold-standard flaw, all surfaced by the eval:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Adding NER dropped EMAIL recall from 1.00 to 0.53. A merge bug glued
model spans across paragraph breaks (&lt;code&gt;name@example.org.\n\nNext Name&lt;/code&gt;
became one NAME span).&lt;/li&gt;
&lt;li&gt;Still 0.53 after the fix - the &lt;em&gt;upstream&lt;/em&gt; HuggingFace pipeline
aggregation produced the same cross-paragraph span. No identifier spans
a line break; split on newlines first.&lt;/li&gt;
&lt;li&gt;EMAIL 0.78 - the model labels &lt;code&gt;darius.ashcombe@example.com&lt;/code&gt; as a
PATIENT name, because emails literally contain patient names. A person
name never contains &lt;code&gt;@&lt;/code&gt;; drop such spans at the source.&lt;/li&gt;
&lt;li&gt;GEO recall pinned at 0.52 no matter what. Not a model bug: the &lt;strong&gt;gold
standard was wrong&lt;/strong&gt; - the corpus planted street and city as two spans
where reality has one postal address, so the model's single correct span
could only ever match half the gold. The tell was 0.52 recall alongside
0.72 precision. Sometimes the eval indicts the gold, not the system.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule of integration, now baked into regression floors: adding a detector&lt;br&gt;
must never make another detector worse.&lt;/p&gt;
&lt;h2&gt;
  
  
  The prose gets real: an injection benchmark
&lt;/h2&gt;

&lt;p&gt;Templates were too easy, and by this point provably so. The next test&lt;br&gt;
injects synthetic identifiers into ~5,000 authentic public&lt;br&gt;
medical-transcription samples (mtsamples.com - downloaded with a pinned&lt;br&gt;
checksum, never redistributed). Injections are unlabeled narrative&lt;br&gt;
sentences woven between real sentences at deterministic positions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;localscrub mtsamples &lt;span class="nt"&gt;--fetch&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;-o&lt;/span&gt; mts.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One methodological point worth stating plainly: on an injection benchmark,&lt;br&gt;
&lt;strong&gt;recall is exact but precision is only a lower bound.&lt;/strong&gt; The real&lt;br&gt;
transcripts contain their own name-like and date-like strings - "Dr. X"&lt;br&gt;
placeholders, real dates - so a detector flagging them is penalized for&lt;br&gt;
being right. Redaction recall over the injected gold is the number to&lt;br&gt;
trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results - including the negative one
&lt;/h2&gt;

&lt;p&gt;Every configuration, both corpora, all at full size, scored by the&lt;br&gt;
identical harness: 500 template notes carrying 5,021 gold spans, and 100&lt;br&gt;
MTSamples notes carrying 697 injected gold spans - both from seed 42.&lt;br&gt;
Redaction recall - the safety metric - plus precision on the&lt;br&gt;
authentic-prose corpus, where over-flagging shows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;config&lt;/th&gt;
&lt;th&gt;template&lt;/th&gt;
&lt;th&gt;MTSamples&lt;/th&gt;
&lt;th&gt;MTS precision†&lt;/th&gt;
&lt;th&gt;latency/note&lt;/th&gt;
&lt;th&gt;hardware&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Presidio (rules-only baseline)&lt;/td&gt;
&lt;td&gt;0.78&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;0.49&lt;/td&gt;
&lt;td&gt;14–46 ms&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 (rules)&lt;/td&gt;
&lt;td&gt;0.66&lt;/td&gt;
&lt;td&gt;0.62&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;µs&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 + NER&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.999&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;td&gt;184–652 ms&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 + LLM&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;td&gt;7–8 s&lt;/td&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stage 1 + NER + LLM&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.999&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.76&lt;/td&gt;
&lt;td&gt;7–17 s&lt;/td&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;† relaxed precision on the injection benchmark - a lower bound for every&lt;br&gt;
system, per the previous section.&lt;/p&gt;

&lt;p&gt;Because three nines invite scrutiny, here are the raw counts behind the&lt;br&gt;
headline number. 0.999 is &lt;strong&gt;696 of 697&lt;/strong&gt; injected spans fully redacted&lt;br&gt;
(Wilson 95% CI 0.992–0.9997); one more miss would read 0.997, so treat the&lt;br&gt;
third digit as "one miss in this sample," not a stability claim. And the&lt;br&gt;
one miss deserves naming: in &lt;code&gt;2034 Harrowgate Rd, Lantern Hill, VT 93695&lt;/code&gt;,&lt;br&gt;
the NER covered the street line ("2034 Harrowgate Rd") and the&lt;br&gt;
state-plus-ZIP ("VT 93695") but dropped the city - "Lantern Hill" leaked&lt;br&gt;
from between two redactions. The&lt;br&gt;
address-clipping failure mode, surviving at the very tail. (The template&lt;br&gt;
0.94, for comparison, is 4,711 of 5,021 - 310 misses; at that sample size&lt;br&gt;
the second digit is doing honest work.)&lt;/p&gt;

&lt;p&gt;Two findings, one of them a negative result I think the field under-reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On synthetic templates, the LLM buys nothing.&lt;/strong&gt; Stage 1 + NER,&lt;br&gt;
stage 1 + LLM, and the full cascade all converge at 0.94 redaction recall -&lt;br&gt;
and at 0.97 relaxed F1 - at latencies spanning 184 milliseconds to 17&lt;br&gt;
seconds. The residual 6% is corpus-bound, not detector-bound. If your text&lt;br&gt;
is structured and identifier-dense, a good token classifier is all the&lt;br&gt;
model you need, and it runs on CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On authentic prose, the LLM earns its keep.&lt;/strong&gt; Bare rules manage 0.62;&lt;br&gt;
adding the LLM lifts that to 0.967; NER+LLM reaches 0.999. The two&lt;br&gt;
detectors compose exactly as the merge was designed to: the LLM still&lt;br&gt;
clips addresses, NER still covers them. But recall is not free - the&lt;br&gt;
precision column tells the other half. The cascade over-flags on narrative&lt;br&gt;
text (0.81 → 0.76 versus NER alone, both lower bounds), and over-redaction&lt;br&gt;
has a real cost in clinical text: every falsely scrubbed token is signal a&lt;br&gt;
downstream reader loses. That trade is why localscrub's model is&lt;br&gt;
review-and-attest rather than fire-and-forget - and note the baseline pays&lt;br&gt;
the same toll, with Presidio at 0.49 precision on this corpus. Recall is&lt;br&gt;
what the LLM buys; know which side of the trade your application needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Versus Presidio, fairly
&lt;/h3&gt;

&lt;p&gt;Microsoft's Presidio (rules + spaCy, the standard open-source baseline)&lt;br&gt;
&lt;strong&gt;beats bare stage 1&lt;/strong&gt; on redaction recall - 0.78 vs 0.66 - because spaCy&lt;br&gt;
gives it person and place names, which stage 1 intentionally defers. It is&lt;br&gt;
also 13–14× faster than our recommended CPU configuration, and its NAME&lt;br&gt;
boundaries are &lt;em&gt;better&lt;/em&gt; than our NER extra's (strict F1 0.87 vs 0.73).&lt;br&gt;
Every scoring ambiguity was resolved in the baseline's favor - its unmapped&lt;br&gt;
types still earn redaction credit.&lt;/p&gt;

&lt;p&gt;With the NER extra, localscrub wins where a leak hurts most. Presidio never&lt;br&gt;
fully covered a single gold address on either corpus - spaCy tags "Dayton"&lt;br&gt;
but drops "412 Birch Lane" - and it has no MRN or health-plan recognizer,&lt;br&gt;
fully redacting 2–6% of MRNs and ≤15% of plan IDs. localscrub holds those&lt;br&gt;
at 1.00 in every configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 17-minute specialist
&lt;/h2&gt;

&lt;p&gt;A fair question at this point: if a pretrained 125M classifier already hits&lt;br&gt;
0.999, why train anything? Because a token classifier cannot take stage 2's&lt;br&gt;
seat. It tags a fixed label set - ask it about an identifier type it wasn't&lt;br&gt;
trained on and it has no opinion - and it cannot adjudicate the ambiguous&lt;br&gt;
spans stage 1 escalates. Stage 2's contract, verbatim snippets plus types&lt;br&gt;
as JSON, is a conversation, and only an instruction-following model can&lt;br&gt;
hold up its end. The 14B holds it up at seven seconds a note. The question&lt;br&gt;
worth 17 minutes of GPU time is whether a small model can be &lt;em&gt;taught&lt;/em&gt; to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;localscrub sft&lt;/code&gt; emits 2,000&lt;br&gt;
training pairs - the exact inference-time stage-2 prompt, escalation hints&lt;br&gt;
included, paired with gold JSON - and a LoRA recipe (r=16, bf16) tunes&lt;br&gt;
Qwen3-1.7B-Base in 17 minutes on the laptop.&lt;/p&gt;

&lt;p&gt;The before/after is stark, but not where I expected. The base 1.7B model&lt;br&gt;
produced unparseable output on &lt;strong&gt;35 of 35&lt;/strong&gt; notes; the tuned one failed on&lt;br&gt;
0 of 80. The fine-tune's first product is not accuracy - it's&lt;br&gt;
&lt;em&gt;parseability&lt;/em&gt;. Accuracy followed: MTSamples redaction recall 0.61 → 0.92.&lt;br&gt;
(Template recall hit a perfect 1.000, which is optimistic by construction -&lt;br&gt;
train and eval share note skeletons; the docs say so.)&lt;/p&gt;

&lt;p&gt;The 0.92-vs-0.999 gap against the big-model cascade is a training-data&lt;br&gt;
diversity gap, not a capacity verdict - 2,000 examples from 7 templates&lt;br&gt;
generalize only partway to real prose, and the recipe documents the fix&lt;br&gt;
(mix in MTSamples-injected and diversified notes). The deeper point: the&lt;br&gt;
synthetic corpus is the asset. Data, training, and eval all regenerate from&lt;br&gt;
seeds; the specialist retrains from scratch in under half an hour on&lt;br&gt;
consumer hardware, with no real PHI anywhere in the loop - including the&lt;br&gt;
prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational lessons (the part I wish someone had written for me)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One bad note must not cost you the run.&lt;/strong&gt; Stage-2 latency is bimodal:&lt;br&gt;
~5 s per note typically, ~50 s when schema-constrained decoding runs away&lt;br&gt;
to the 4,096-token cap - and in each 500-note pass, exactly one template&lt;br&gt;
note (deterministic at temperature 0) stalled the Ollama server for&lt;br&gt;
minutes before returning HTTP 500. The first time, that single note killed&lt;br&gt;
a 2-hour-50-minute benchmark at note ~495 with nothing written, because the&lt;br&gt;
eval had no per-note error handling. The fix - retry the note once, then&lt;br&gt;
score it without stage 2 and report an &lt;code&gt;llm_failures&lt;/code&gt; count - turned the&lt;br&gt;
second occurrence into a 4.8-second retry-and-continue. If you benchmark&lt;br&gt;
local LLMs, build this in before your first long run, not after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap your decoders.&lt;/strong&gt; Uncapped schema-constrained generation once looped&lt;br&gt;
past a ten-minute timeout. A &lt;code&gt;num_predict&lt;/code&gt; cap plus a salvage parser (parse&lt;br&gt;
the longest well-formed prefix of a truncated extraction list; every item&lt;br&gt;
is independently verified against the source anyway) converts runaway&lt;br&gt;
decoding from a crash into a bounded cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assorted potholes:&lt;/strong&gt; a gitignore &lt;em&gt;trailing comment&lt;/em&gt; silently unignored a&lt;br&gt;
17 MB dataset and it reached the git index once; Blackwell GPUs need cu13x&lt;br&gt;
torch builds and uv needed &lt;code&gt;--reinstall&lt;/code&gt; with an explicit &lt;code&gt;+cu130&lt;/code&gt; pin to&lt;br&gt;
swap them; an untuned base model with no token cap looks exactly like a&lt;br&gt;
frozen process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats, stated rather than buried
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The template-corpus numbers are on easy, identifier-dense synthetic
prose; MTSamples-injection is the authentic-prose test, and the numbers
held there. But injected identifiers are not naturally occurring PHI:
real i2b2-style evaluation remains the gap.&lt;/li&gt;
&lt;li&gt;On data leakage: MTSamples is a well-known public corpus, so the
&lt;em&gt;transcripts&lt;/em&gt; may well sit in a model's pretraining data
(&lt;code&gt;obi/deid_roberta_i2b2&lt;/code&gt; was trained on i2b2 2014, not MTSamples; for
the LLM's pretraining the honest answer is unknown). The scored
identifiers, however, are not MTSamples content: every gold span is
synthetic, seeded, and injected - the identifier strings are generated,
not drawn from the transcripts, so memorizing MTSamples does not hand a
model the answers. What leakage &lt;em&gt;could&lt;/em&gt; do is make the surrounding prose feel
familiar, flattering the numbers relative to truly unseen clinical
text - one more reason the i2b2 evaluation (behind a data use
agreement, unlikely to be memorized) is the next benchmark.&lt;/li&gt;
&lt;li&gt;Injection-benchmark precision is a lower bound for every system measured.&lt;/li&gt;
&lt;li&gt;Stage-2 reproducibility is best-effort: fixed seed, temperature 0, but
tied to model build and hardware.&lt;/li&gt;
&lt;li&gt;No real PHI was used anywhere, at any step - including in every prompt
sent to the local model.&lt;/li&gt;
&lt;li&gt;localscrub does not claim HIPAA Safe Harbor certification. It reduces the
problem to review-and-attest; residual-risk sign-off stays human. The
&lt;a href="https://github.com/valbarov/localscrub/blob/main/docs/threat-model.md" rel="noopener noreferrer"&gt;threat model&lt;/a&gt;
spells out the trust boundary, including what a hijacked stage-2 model
can and cannot do (it can over-redact or falsely confirm; it can never
unredact).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Two measurements are deliberately absent, and they are the next article.&lt;br&gt;
&lt;strong&gt;Cloud de-id APIs&lt;/strong&gt;: the accuracy/latency/cost legs require sending the&lt;br&gt;
benchmark corpora to each provider - synthetic or not, that crossing of the&lt;br&gt;
trust boundary deserves its own explicit decision and write-up, because it&lt;br&gt;
is the exact trade this project exists to interrogate. And &lt;strong&gt;i2b2/n2c2&lt;br&gt;
2014&lt;/strong&gt;: the literature-comparable corpus of naturally occurring PHI, access&lt;br&gt;
request filed and pending. When both land, the comparison table gets its&lt;br&gt;
last rows.&lt;/p&gt;

&lt;p&gt;Until then: the code is on &lt;a href="https://github.com/valbarov/localscrub" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, the package is on&lt;a href="https://pypi.org/project/localscrub/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;, and every number above regenerates from a seed. Check my math.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
