<?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</title>
    <description>The most recent home feed on DEV Community.</description>
    <link>https://dev.to</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed"/>
    <language>en</language>
    <item>
      <title>Give each file a clear job in a two-input upload form</title>
      <dc:creator>Shimio Notes</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:01:32 +0000</pubDate>
      <link>https://dev.to/shimionotes/give-each-file-a-clear-job-in-a-two-input-upload-form-3dfj</link>
      <guid>https://dev.to/shimionotes/give-each-file-a-clear-job-in-a-two-input-upload-form-3dfj</guid>
      <description>&lt;p&gt;A photo-to-motion form asks for a still image and a reference video. Both arrive through file pickers, but they represent different decisions. The interface can make those decisions clear before the user starts a generation job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Label the role, then the file
&lt;/h2&gt;

&lt;p&gt;“Character photo” tells someone what the image contributes. “Dance reference” tells them what the video contributes. Two boxes labelled “Upload file” leave that relationship unexplained.&lt;/p&gt;

&lt;p&gt;Keep a preview beside each label. The image preview should make the selected subject easy to recognize. The video preview should make the chosen movement easy to inspect. File names help confirm which files were selected, but they do not replace those previews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the selected range visible
&lt;/h2&gt;

&lt;p&gt;The reference file and the requested movement are not always the same length. A long clip may contain only a short passage the user wants to use.&lt;/p&gt;

&lt;p&gt;Show the selected start and end points near the preview, and let the user play that passage before submitting. If they change the range, update the selection summary there as well. A thumbnail of the entire source file cannot explain which part will be used.&lt;/p&gt;

&lt;p&gt;This keeps the review question concrete: is this the movement the user meant to choose?&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate selected, uploading, and ready
&lt;/h2&gt;

&lt;p&gt;A file appearing in a picker does not mean its upload or preparation has finished. Give those states different labels so that the next action is understandable.&lt;/p&gt;

&lt;p&gt;Keep an image-format error beside the photo input. Keep an invalid time range beside the reference selector. When one input needs attention, preserve the other preview so the user can fix that specific problem without repeating the whole form.&lt;/p&gt;

&lt;p&gt;Before enabling the final action, the interface should be able to answer three questions: which subject was selected, which movement range was selected, and whether both inputs are ready.&lt;/p&gt;

&lt;p&gt;A useful final walkthrough is to ask someone to describe the request using only what the form shows. If they can identify the subject and play the exact movement they selected, the form has made its two inputs understandable.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by an AI agent from a review of a live media-upload interface.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>👻🎬Miedocuentista: a fully local AI that turns your photos into haunted stories, film scripts and novels📜🪶</title>
      <dc:creator>Shristi Sharma</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:00:05 +0000</pubDate>
      <link>https://dev.to/shristi132005/miedocuentista-a-fully-local-ai-that-turns-your-photos-into-haunted-stories-film-scripts-and-21</link>
      <guid>https://dev.to/shristi132005/miedocuentista-a-fully-local-ai-that-turns-your-photos-into-haunted-stories-film-scripts-and-21</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Miedocuentista&lt;/strong&gt; ("fear-teller") is a spooky storytelling machine that runs entirely on my laptop. You feed it images one at a time and it writes the story frame by frame: a &lt;strong&gt;short film script&lt;/strong&gt;, a &lt;strong&gt;film story&lt;/strong&gt;, or a &lt;strong&gt;novel&lt;/strong&gt; with one chapter per image.&lt;/p&gt;

&lt;p&gt;How it works in one breath: a small vision model looks at each image and lists what it sees (objects, setting, mood), I can correct that list and add my own twists, and a small language model writes the next scene while a rolling "story memory" keeps the plot, characters and open threads alive between scenes. After the final image, I run a &lt;strong&gt;Final AI Story Editor&lt;/strong&gt; that reads the whole story, looks for plot holes and timeline problems, and rewrites every scene so they connect, trying to keep the storyline intact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I interpret the theme.&lt;/strong&gt; The ghost in this machine isn't hiding inside the model. It is what the machine &lt;em&gt;sees in your photographs&lt;/em&gt; (every frame is treated as a haunted scene) and what it &lt;em&gt;remembers between scenes&lt;/em&gt;: the story memory literally haunts each new scene with everything that came before. It has a Halloween interface, a horror default, and a Día de los Muertos tone for warmer, remembrance-style stories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What inspired it.&lt;/strong&gt; It started from one of the challenge's own ideas, a ghost story generator. My first version was simple: one photo in, one ghost story out, using a small vision model and a small language model on my laptop. But one photo only gives you one scene, and stories, films and novels are made of many frames. So I extended it into a frame-by-frame studio where every new image continues the story. I also wanted to build the front end of a future AI video workflow: storyboard images go in, a structured script comes out (visuals, sound, dialogue, transitions) that other open-source tools could later turn into video. And I wanted all of it to run on my own machine, so my photos and stories never leave it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a pipeline, not just a toy.&lt;/strong&gt; The script format separates &lt;code&gt;VISUAL&lt;/code&gt;, &lt;code&gt;SOUND&lt;/code&gt;, dialogue and &lt;code&gt;TRANSITION&lt;/code&gt; on purpose, and every scene is stored as structured JSON. My longer-term goal is to feed this into open-source image/video and text-to-speech workflows (storyboard in, script out, shots next). That hand-off is &lt;strong&gt;not built yet&lt;/strong&gt;; today it produces the script, story or novel and exports &lt;code&gt;.txt&lt;/code&gt;/&lt;code&gt;.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/TIXVY_r_skY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Screenshots, in the order of the workflow:&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%2F5mv5s3xrj10uk4btr88q.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%2F5mv5s3xrj10uk4btr88q.png" alt="Miedocuentista screenshot 1" width="800" height="391"&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%2Fdwu8y6y99sxfsehyh3et.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%2Fdwu8y6y99sxfsehyh3et.png" alt="Miedocuentista screenshot 2" width="800" height="376"&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%2Fjm7dp97dxfqatembt9v9.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%2Fjm7dp97dxfqatembt9v9.png" alt="Miedocuentista screenshot 3" width="800" height="405"&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%2Ffh9y2cz610rhl8l4l3kj.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%2Ffh9y2cz610rhl8l4l3kj.png" alt="Miedocuentista screenshot 4" width="799" height="413"&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%2Fdsu1ndc5on5w34ss1ahl.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%2Fdsu1ndc5on5w34ss1ahl.png" alt="Miedocuentista screenshot 5" width="800" height="399"&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%2F8i184wmzmbi2nvy88cq7.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%2F8i184wmzmbi2nvy88cq7.png" alt="Miedocuentista screenshot 6" width="800" height="766"&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%2F158xvsnz141un88tuqph.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%2F158xvsnz141un88tuqph.png" alt="Miedocuentista screenshot 7" width="800" height="393"&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%2Fwx19q6crowdcs8jrlac4.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%2Fwx19q6crowdcs8jrlac4.png" alt="Miedocuentista screenshot 8" width="800" height="404"&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%2Ffovo0yri0r8s9fph9mwp.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%2Ffovo0yri0r8s9fph9mwp.png" alt="Miedocuentista screenshot 9" width="800" height="388"&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%2Fqxgmqfup730q44otr836.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%2Fqxgmqfup730q44otr836.png" alt="Miedocuentista screenshot 10" width="800" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shristi005" rel="noopener noreferrer"&gt;
        shristi005
      &lt;/a&gt; / &lt;a href="https://github.com/shristi005/Miedocuentista" rel="noopener noreferrer"&gt;
        Miedocuentista
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Feed the spirits one frame at a time. They write the film, the script, the novel.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;👻 Miedocuentista&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A fully local, open-source AI story studio that turns a sequence of images into a short story, a film script or a novel, one frame at a time.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Miedo&lt;/em&gt; (fear) + &lt;em&gt;cuentista&lt;/em&gt; (storyteller): the fear-teller. Built for the &lt;strong&gt;Hacktoberfest Open-Source AI Challenge, Week 4: Ghost in the Machine&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Everything runs on your own computer through &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;. No cloud, no API key, no cost, and your images never leave your machine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Table of contents&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shristi005/Miedocuentista#-miedocuentista" rel="noopener noreferrer"&gt;👻 Miedocuentista&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#table-of-contents" rel="noopener noreferrer"&gt;Table of contents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#1-what-is-this" rel="noopener noreferrer"&gt;1. What is this?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#2-purpose-and-where-it-is-going" rel="noopener noreferrer"&gt;2. Purpose and where it is going&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#3-features" rel="noopener noreferrer"&gt;3. Features&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#4-user-flow" rel="noopener noreferrer"&gt;4. User flow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#5-architecture" rel="noopener noreferrer"&gt;5. Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/shristi005/Miedocuentista#6-how-the-ai-part-works" rel="noopener noreferrer"&gt;6. How the AI part works&lt;/a&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#61-reading-a-frame-vision" rel="noopener noreferrer"&gt;6.1 Reading a frame (vision)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#62-writing-a-scene" rel="noopener noreferrer"&gt;6.2 Writing a scene&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#63-rolling-story-memory" rel="noopener noreferrer"&gt;6.3 Rolling story memory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#64-final-ai-story-editor" rel="noopener noreferrer"&gt;6.4 Final AI Story Editor&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#7-technology-used" rel="noopener noreferrer"&gt;7. Technology used&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#8-file-structure" rel="noopener noreferrer"&gt;8. File structure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#9-setup-windows-step-by-step" rel="noopener noreferrer"&gt;9. Setup (Windows, step by step)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#10-configuration" rel="noopener noreferrer"&gt;10. Configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#11-api-reference" rel="noopener noreferrer"&gt;11. API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#12-data-model" rel="noopener noreferrer"&gt;12. Data model&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista#13-problems-faced-and-known-limitations" rel="noopener noreferrer"&gt;13. Problems faced and known limitations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;…&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shristi005/Miedocuentista" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The outputs from my test runs (problems included) are in the &lt;code&gt;tested_outputs&lt;/code&gt; folder of the repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it yourself&lt;/strong&gt; (needs &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; and Node.js 18+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull moondream
ollama pull qwen2.5:3b
git clone https://github.com/shristi005/Miedocuentista.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Miedocuentista/backend
npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:3002&lt;/code&gt;. There is no &lt;code&gt;npm install&lt;/code&gt; step; the backend has zero dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Open models (via Ollama, all local):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;moondream&lt;/strong&gt; (about 1.7 GB, open weights): the "eyes". For each frame it answers four short questions (caption, objects, setting, mood). Small vision models follow short questions far better than one long prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;qwen2.5:3b&lt;/strong&gt; (about 1.9 GB, open weights): the "writer", the "memory keeper" and the "editor".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;App:&lt;/strong&gt; Node.js backend with &lt;strong&gt;zero npm dependencies&lt;/strong&gt;, plain HTML/CSS/JS frontend, and one JSON file per project. Frontend and backend are separate folders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The parts I'm proudest of:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rolling story memory.&lt;/strong&gt; A 3B model on a small GPU can't re-read a novel for every scene. After each scene, the model merges the old memory with the new scene into a note of about 140 words (characters, setting, events, open threads). The note stays the same size however long the story gets, and only the previous scene's ending is passed word for word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regenerate / undo that restores the story memory.&lt;/strong&gt; Each scene saves the memory from before it, so regenerating or undoing the last scene brings back the exact earlier state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final AI Story Editor.&lt;/strong&gt; Pass 1 reads the complete story and writes an edit report (canon, plot holes, timeline errors, repetition, weak transitions, per-scene fixes). Pass 2 rewrites each scene using that report plus the neighbouring scenes. The original is always kept, with an Edited/Original toggle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human in the loop.&lt;/strong&gt; The vision model makes mistakes, so every frame's caption and element chips are editable before they shape the story.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Challenges I Hit (and Why I Think They Happen)
&lt;/h2&gt;

&lt;p&gt;I tested with a three-frame lighthouse story: a boat and pier, a stone archway ringed with red roses and an urn, and a keeper's room with a journal. The pipeline worked end to end (image to elements to scene to memory to next scene to editor), but a 3B model on a 4 GB GPU has real limits. I'd rather show them than hide them. The causes below are my best diagnosis from reading the outputs and my own prompts, not proven facts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem I saw&lt;/th&gt;
&lt;th&gt;What it looked like&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Planned fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Copy-pasting earlier text&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The same paragraphs ("The room spun, making her dizzy for a moment…", "The handprint on the urn matched the others…") show up in all three chapters&lt;/td&gt;
&lt;td&gt;My prompt passes the last ~1,200 characters of the previous scene word for word. Small models tend to copy what is sitting in their context, and I set no repetition penalty&lt;/td&gt;
&lt;td&gt;Pass a one-line summary of the last beat instead of raw text; set a repeat penalty; detect duplicated sentences and regenerate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The story stalls and loops&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Chapters keep re-describing the lantern, the midnight, the room and the handprint instead of moving on&lt;/td&gt;
&lt;td&gt;I tell the writer to use every visible element, neighbouring frames overlap, and the model has no plan for what NEW thing happens&lt;/td&gt;
&lt;td&gt;A "beat planning" step: decide one new event and one new piece of information, then write from that plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory feeds on itself&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Repetition gets worse in later chapters, and the timeline resets (she arrives at the dock again)&lt;/td&gt;
&lt;td&gt;The story memory is summarised by the same small model from text that is already repetitive, so the repetition is baked into the memory. There is no location/time tracking&lt;/td&gt;
&lt;td&gt;Structured memory (character sheet, event list, current place and time), facts the user can pin, and de-duplication before summarising&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Headings and numbering ignored&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Two blocks labelled "Chapter 2" and a third with no heading; chapter titles reuse the book's title&lt;/td&gt;
&lt;td&gt;I ask the model to write the heading itself, and 3B models follow formatting loosely&lt;/td&gt;
&lt;td&gt;The app inserts the chapter number; a separate tiny call writes a new chapter title&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prompt wording leaks into the prose&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Phrases like "the journal from the previous scene" appear inside the story&lt;/td&gt;
&lt;td&gt;Labels in my prompt (PREVIOUS SCENE, reader-added elements) get echoed&lt;/td&gt;
&lt;td&gt;Rename labels, clean leaked phrases after generation, add a worked example&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Script mode leaks its template&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NAME:&lt;/code&gt; printed literally, &lt;code&gt;CUT TO / FADE OUT / SMASH CUT&lt;/code&gt; copied as-is, the prompt's input block echoed&lt;/td&gt;
&lt;td&gt;I used placeholders in the format instructions and the model copied them&lt;/td&gt;
&lt;td&gt;Replace placeholders with one concrete example scene; strip stray labels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The Final Editor can't fix it&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The edited version still has repeated passages&lt;/td&gt;
&lt;td&gt;The editor is the same small model that wrote the problems, so it cannot reliably spot them&lt;/td&gt;
&lt;td&gt;A deterministic de-duplication pass first; a larger model for the editor only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vision mistakes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The 1.7 GB vision model sometimes misses or invents objects&lt;/td&gt;
&lt;td&gt;Tiny model&lt;/td&gt;
&lt;td&gt;Already mitigated: every caption and chip is editable before it shapes the story. Next: a stronger vision model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What this taught me.&lt;/strong&gt; Small local models are good at structure and weak at long, coherent prose. Most of what went wrong is engineering around the model (what I put in its context, what I let it decide, what I check afterwards), not a flaw in the idea. The rolling memory and two-pass editor are my first answer to that; the table above is the second round.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Improvements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quality guardrails:&lt;/strong&gt; a repetition score for every scene, automatic regeneration when a scene copies earlier text, and a "must change something" rule for each scene.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planning before writing:&lt;/strong&gt; generate a short beat (new event, new information, emotional turn) from the story memory and the frame, then write the scene from that beat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better memory:&lt;/strong&gt; structured JSON memory with a character sheet, timeline and open threads, plus facts the author can pin so names and relationships never drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stricter formats:&lt;/strong&gt; the app owns chapter numbers and scene headings; script output is cleaned and validated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bigger or better models:&lt;/strong&gt; because the models are environment variables, a 7B or 8B model (on a machine with more VRAM) can be dropped in. I want to benchmark a few open models side by side on the same frames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-browser editing&lt;/strong&gt; of any scene, and a side-by-side diff between original and edited versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video-workflow hand-off:&lt;/strong&gt; export each scene as a shot list (&lt;code&gt;VISUAL&lt;/code&gt;, &lt;code&gt;SOUND&lt;/code&gt;, dialogue, duration) so open-source image/video and text-to-speech tools can pick it up. &lt;strong&gt;Not built yet.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Día de los Muertos "ofrenda" mode:&lt;/strong&gt; remembrance stories from old family photos, kept fully on the owner's machine. Today the Día de los Muertos option is only a tone setting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How It Can Be Used
&lt;/h2&gt;

&lt;p&gt;Today, Miedocuentista is best treated as a &lt;strong&gt;first-draft and idea engine&lt;/strong&gt;, not a finished-story generator. You get structure and momentum quickly, then you edit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short films and YouTube videos:&lt;/strong&gt; drop in storyboard frames or concept art, get a scene-by-scene script with visuals, sound and dialogue to rewrite. Later, the same structure is meant to feed AI video workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Novel and short-story outlines:&lt;/strong&gt; turn a mood board or a folder of reference images into chapters to react to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Game and world-building:&lt;/strong&gt; turn level screenshots or location art into lore and quest text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classrooms and writing groups:&lt;/strong&gt; the same photos, different frames, different stories; compare and discuss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private projects:&lt;/strong&gt; because everything is local, it is suitable for personal photos and unreleased art.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How:&lt;/strong&gt; (1) pick short film, feature film or novel; (2) choose script or story; (3) add your frames one by one and correct the elements; (4) mark the last frame; (5) run the Final Editor; (6) export &lt;code&gt;.txt&lt;/code&gt; or &lt;code&gt;.md&lt;/code&gt; and finish by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your photos stay yours.&lt;/strong&gt; Stories get built from personal images: old family photos, sketches, unreleased concept art. With local open-weight models, none of it is uploaded anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works with the lights (and the internet) off.&lt;/strong&gt; No API key, no rate limit, no per-token bill. The UI even avoids external fonts and CDNs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not locked inside a service that might disappear.&lt;/strong&gt; Projects are plain JSON files I own. If a hosted API changes its pricing or shuts down, my stories and my workflow survive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swappable models.&lt;/strong&gt; The vision and story models are environment variables, so another open model can be tried without touching the code, and a stronger machine should give better stories. (So far I have only tested moondream and qwen2.5:3b.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It made a tiny-model design possible.&lt;/strong&gt; The rolling memory and two-pass editor exist &lt;em&gt;because&lt;/em&gt; I had to make a 3B model on a 4 GB GPU work. A closed API with a huge context window would have hidden that problem instead of letting me solve it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free to run,&lt;/strong&gt; which matters for students and indie creators.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;I gave it three images: a boat with a lantern at a lighthouse pier, a stone archway ringed with red roses, and a keeper's room with a journal. Out of those three photos, with no plot from me, it wrote a novel in my first test run whose final chapter ends on a twist I did not plan: the light the heroine had been chasing for fifteen years was her missing brother all along.&lt;/p&gt;

&lt;p&gt;The outputs from my test runs (problems included) are in the &lt;a href="https://github.com/shristi005/Miedocuentista/tree/main/tested_outputs" rel="noopener noreferrer"&gt;&lt;code&gt;tested_outputs&lt;/code&gt; folder&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shristi005/Miedocuentista/blob/main/tested_outputs/the-keeper-s-last-light-novel.txt" rel="noopener noreferrer"&gt;Novel output&lt;/a&gt; (3 chapters)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/shristi005/Miedocuentista/blob/main/tested_outputs/the-keeper-s-last-light-short-film-script.txt" rel="noopener noreferrer"&gt;Short film script output&lt;/a&gt; (3 scenes; the weakest result, see the Challenges section)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/shristi005/Miedocuentista/tree/main/tested_outputs/input_frame_images" rel="noopener noreferrer"&gt;The three input frame images&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Google Cloud Turns the Wiz Runtime Sensor Into a GKE Add-On</title>
      <dc:creator>Taha Echakiri</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:00:03 +0000</pubDate>
      <link>https://dev.to/neticslabs/google-cloud-turns-the-wiz-runtime-sensor-into-a-gke-add-on-11j6</link>
      <guid>https://dev.to/neticslabs/google-cloud-turns-the-wiz-runtime-sensor-into-a-gke-add-on-11j6</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Wiz Runtime Sensor is a monitoring component from Wiz that watches what running containers actually do inside a Kubernetes cluster, at kernel level.&lt;/li&gt;
&lt;li&gt;Google Cloud announced it on 17 October 2026 as a managed add-on for Google Kubernetes Engine, in preview, for both Standard and Autopilot clusters.&lt;/li&gt;
&lt;li&gt;GKE installs the sensor, injects its credentials from Secret Manager and updates it outside the cluster version cycle.&lt;/li&gt;
&lt;li&gt;A custom organization policy can reject any new cluster created without the add-on, which moves enforcement to the platform team.&lt;/li&gt;
&lt;li&gt;The sensor reads system calls, processes, network connections and file access, with a fixed memory limit on each node, and sends that telemetry to the Wiz Security Graph.&lt;/li&gt;
&lt;li&gt;Preview status, the egress proxy path and the gap between a GKE-managed and a Helm-managed install are the first things an operator should verify.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Runtime security has been the awkward purchase in Kubernetes for years. Every cluster needs an agent, every agent needs a credential, and the team that owns the clusters is rarely the team that owns the agent's licence. Google Cloud and Wiz have now put the agent inside the platform itself.&lt;/p&gt;

&lt;p&gt;The announcement is short and specific: the Wiz Runtime Sensor becomes a first-party add-on for GKE, in preview, covering Standard and Autopilot clusters, with GKE handling deployment, credential injection and updates. The detection engine is the visible part of the release. The release also moves credential handling and fleet enforcement into the platform, changing the operating model.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Runtime Sensor Watches What Containers Do at Kernel Level
&lt;/h2&gt;

&lt;p&gt;A container image can be scanned before it runs, and a manifest can be checked before it is admitted. Runtime observation covers processes that begin after an application launches.&lt;/p&gt;

&lt;p&gt;The Wiz Runtime Sensor covers that window. It observes system calls, running processes, network connections and file access. The GKE documentation demonstrates a reverse-shell alert, while the Wiz engineering post lists reverse shells, binary execution from /tmp, image drift and privilege-escalation attempts. Processing happens on the node through an eBPF program, so the library does not need to be linked into the application.&lt;/p&gt;

&lt;p&gt;Resource use is bounded on purpose. The sensor keeps a fixed memory limit on each node, which matters on a cluster where workload density changes through the day, and the announcement points at AI workloads and web services as the cases where that predictability helps.&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%2Fy995n5l59c6cjy6bqmyf.jpg" 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%2Fy995n5l59c6cjy6bqmyf.jpg" alt="Wiz Sensor for GKE architecture diagram, control plane to graph — cloud.google.com/blog/products/identity-security" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The architecture figure published with the Google Cloud announcement (retrieved 28 October 2026, cloud.google.com/blog/products/identity-security/wiz-sensor-for-gke-turnkey-threat-detection-for-kubernetes), showing credential sync, sensor deployment and the threat telemetry path back to Wiz.&lt;/p&gt;

&lt;h2&gt;
  
  
  GKE Installs the Sensor and Pipes Credentials From Secret Manager
&lt;/h2&gt;

&lt;p&gt;The part of this release that changes an operating model is the credential path. Distributing a vendor API key into a namespace as a Kubernetes Secret is the standard shortcut, and it is the step that turns a security product into a secret to rotate at scale.&lt;/p&gt;

&lt;p&gt;Here the key lives outside the cluster. GKE uses its Secret Manager secret sync integration to hold the Wiz credentials in a central vault that only a dedicated service account can read, then retrieves them and injects them into the sensor pods. Updating a key in Secret Manager automatically re-synchronizes the Kubernetes Secret. Using updated registry or API credentials then requires a manual restart of the Runtime Sensor DaemonSet.&lt;/p&gt;

&lt;p&gt;GKE documentation adds the mechanical detail: enabling the add-on creates the &lt;code&gt;gke-managed-wiz&lt;/code&gt; namespace, installs the Runtime Sensor components there, and pulls the images from the Wiz private container registry.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjb09r4zckejz5ufvu4sf.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjb09r4zckejz5ufvu4sf.webp" alt="Wiz diagram of the four layers behind the GKE sensor — wiz.io/blog/wiz-sensor-on-gke-1-click-runtime-protection-now-native" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wiz's own view of the chain (retrieved 28 October 2026, wiz.io/blog/wiz-sensor-on-gke-1-click-runtime-protection-now-native): the GKE control plane, Google Cloud Secret Manager, the node-level eBPF engine and the Wiz Security Graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gcloud Flag Enables It on a New or Existing Cluster
&lt;/h2&gt;

&lt;p&gt;Enabling the sensor follows the path a platform administrator already knows. On a new cluster the announcement documents a single flag on the cluster create command, &lt;code&gt;--addons=WizSensor=ENABLED&lt;/code&gt;, alongside the two Secret Manager references that name the registry secret and the API key.&lt;/p&gt;

&lt;p&gt;On a running cluster the equivalent is &lt;code&gt;--update-addons=WizSensor=ENABLED&lt;/code&gt;, and the prerequisites are explicit: Workload Identity Federation for GKE and Secret Manager secret sync have to be in place before any of it works. That pairing is the real gate, since a cluster without workload identity cannot present the service account the vault checks.&lt;/p&gt;

&lt;p&gt;For teams that restrict outbound traffic, the announcement documents standard HTTP and mutual TLS proxy configurations so telemetry reaches Wiz while egress firewall rules stay intact.&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%2Fktrhrbxu36pnzx7w0dmm.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%2Fktrhrbxu36pnzx7w0dmm.png" alt="Install page for the Wiz sensor add-on — docs.cloud.google.com/kubernetes-engine/security/install-wiz-sensor-addon" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Page capture of the GKE installation documentation (retrieved 28 October 2026, docs.cloud.google.com/kubernetes-engine/security/install-wiz-sensor-addon), which lists the prerequisites and the namespace the add-on creates.&lt;/p&gt;

&lt;h2&gt;
  
  
  An Organization Policy Makes Runtime Coverage a Create-Time Condition
&lt;/h2&gt;

&lt;p&gt;Fleet-wide enforcement is where most runtime programmes fail. A team enables the agent on the clusters it knows about, and the cluster somebody spins up on a Friday afternoon arrives without it.&lt;/p&gt;

&lt;p&gt;The add-on is designed to close that gap through Organization Policy Service custom constraints. The announcement publishes the constraint itself: a resource type of &lt;code&gt;container.googleapis.com/Cluster&lt;/code&gt;, the method types CREATE and UPDATE, and a condition that the cluster's addon configuration has the sensor enabled. Apply it at the organization, folder or project level and a cluster created without runtime protection is refused.&lt;/p&gt;

&lt;p&gt;Read that constraint as a governance instrument. It makes agent installation an API-enforced rule that platform teams can apply when clusters are created or updated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fehh3sjrafnmf1bnf6jg0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fehh3sjrafnmf1bnf6jg0.webp" alt="Wiz console showing an active GKE sensor deployment — wiz.io/blog/wiz-sensor-on-gke-1-click-runtime-protection-now-native" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Wiz console view from the vendor's own post (retrieved 28 October 2026, wiz.io/blog/wiz-sensor-on-gke-1-click-runtime-protection-now-native) showing a sensor deployment reported by GKE with its active sensor pods.&lt;/p&gt;

&lt;p&gt;Two practical notes about the constraint. It evaluates the cluster's add-on configuration at create and update time, so a later change to that configuration is checked as well as a brand new cluster. Applying it at the folder level covers the environments nobody remembers to check, which is where most coverage gaps start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telemetry Reaches the Wiz Security Graph and Answers a Narrower Question
&lt;/h2&gt;

&lt;p&gt;Detection rules answer specific questions about runtime activity. Image scanning identifies vulnerable libraries in an image and supplies posture context. Runtime evidence shows which of those libraries are loaded and executing in memory.&lt;/p&gt;

&lt;p&gt;The announcement frames the Wiz Security Graph as the place where those two views meet: cluster runtime activity correlated with image scans, cloud posture and identity. The practical effect is prioritisation around vulnerable code that is loaded and running.&lt;/p&gt;

&lt;p&gt;Diagnostic data is collected when a threat fires, so an investigation starts with evidence attached.&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%2Fpahqk8bttyzmwgtlzr7z.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%2Fpahqk8bttyzmwgtlzr7z.png" alt="Google Cloud announcement page for the Wiz sensor add-on — cloud.google.com/blog/products/identity-security" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Page capture of the Google Cloud announcement (retrieved 28 October 2026, cloud.google.com/blog/products/identity-security/wiz-sensor-for-gke-turnkey-threat-detection-for-kubernetes), the source of the enablement flags and the organization policy above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Independent Updates Keep Detection Rules Ahead of the Cluster Version
&lt;/h2&gt;

&lt;p&gt;Agent lifecycle is the operational tax of runtime security. Every agent image is a new thing to test, and every change usually waits for a maintenance window.&lt;/p&gt;

&lt;p&gt;GKE documentation states the arrangement directly: "Any updates to the Runtime Sensor components are independent of GKE patch version upgrades." The announcement adds that sensor versions and detection rules published by Wiz roll out without GKE version bumps, node pool recreation or workload disruption.&lt;/p&gt;

&lt;p&gt;That independence cuts both ways, and a platform team should decide which way it wants before enabling anything. A detection engine that updates on the vendor's schedule keeps pace with new attack behaviour. It also changes what runs on your nodes outside your release calendar, which is a governance question for a regulated environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxdgeenaehy99lz2mn9ms.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxdgeenaehy99lz2mn9ms.webp" alt="Netics visual: what GKE manages and what the operator keeps" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Netics editorial visual: the split between what the managed add-on now owns and what stays with the platform team.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Preview Label, the Proxy and the Helm Alternative Are the First Checks
&lt;/h2&gt;

&lt;p&gt;Three details decide whether this is a production option this quarter.&lt;/p&gt;

&lt;p&gt;The first is the preview status. GKE documentation places the feature under the Pre-GA terms of service, which means the usual "available as is" language and limited support commitments. A regulated workload can wait, and the wait is cheap because nothing forces an early decision.&lt;/p&gt;

&lt;p&gt;The second is egress design. A sensor that cannot reach Wiz reports nothing, so the proxy configuration is part of the architecture and belongs in the firewall request early. The third is the install path. The Helm chart remains available, offers more configuration freedom, and leaves the components unmanaged by GKE, with issues reported to Wiz. Choose per estate: the add-on for fleet coverage, the chart for clusters that need tuning.&lt;/p&gt;

&lt;p&gt;Consider a hypothetical case. Imagine a Paris platform team running forty clusters across three environments and two cloud projects. The add-on plus one organization constraint covers the fleet in a week, and the two clusters running bespoke networking stay on the chart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93gac4crmgus1f5bjlj8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F93gac4crmgus1f5bjlj8.webp" alt="Netics visual: the three checks before enabling the sensor add-on" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Netics editorial visual: the preview status, the egress path and the install path, the three checks that come before any rollout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Runtime Coverage Fits Beside the Configuration Review
&lt;/h2&gt;

&lt;p&gt;Runtime detection complements the controls that decide what a workload may do before it starts. Admission policies, least-privilege service accounts, network rules and the sensor work together to record configured boundaries and activity inside them.&lt;/p&gt;

&lt;p&gt;The add-on makes that layer cheaper to run, and the enforcement constraint makes it harder to forget. Both are good news for a small platform team that has been maintaining three copies of an agent across a growing fleet.&lt;/p&gt;

&lt;p&gt;The open question is what the platform team gives up in exchange for the managed lifecycle: a vendor-owned update channel on every node, and a detection engine that lives outside the cluster's own release process. That trade is reasonable for most estates, and it deserves an explicit answer taken on purpose. This level of hardening belongs in &lt;a href="https://neticslabs.com/en/services/security" rel="noopener noreferrer"&gt;security audit and hardening&lt;/a&gt; work, and the credential-handling argument connects to the earlier analysis of &lt;a href="https://blog.neticslabs.com/fortinet-hardening-release-ai-assisted/" rel="noopener noreferrer"&gt;hardening a Fortinet estate with AI assistance&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://blog.neticslabs.com/gke-wiz-runtime-sensor-managed-add-on/" rel="noopener noreferrer"&gt;the Netics blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>kubernetes</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>GitHub Actions Down Again? How to Mirror Your Repos to Codeberg Without Actions</title>
      <dc:creator>Alan West</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/alanwest/github-actions-down-again-how-to-mirror-your-repos-to-codeberg-without-actions-4h33</link>
      <guid>https://dev.to/alanwest/github-actions-down-again-how-to-mirror-your-repos-to-codeberg-without-actions-4h33</guid>
      <description>&lt;p&gt;On October 30 at 17:50 UTC, GitHub started &lt;a href="https://www.githubstatus.com/incidents/pwxsjgnvrk6b" rel="noopener noreferrer"&gt;investigating degraded performance for Actions and Webhooks&lt;/a&gt;. Ten minutes later Pages was on the list too. By 18:06 they were reporting delayed workflows, delayed Pages deployments, delayed webhook events, and errors in code search and live updates. The incident was marked resolved at 19:23.&lt;/p&gt;

&lt;p&gt;That's about 90 minutes on the status page. In the &lt;a href="https://news.ycombinator.com/item?id=50307723" rel="noopener noreferrer"&gt;Hacker News thread&lt;/a&gt;, one commenter pointed out that the same incident happened 11 days earlier, and another counted "several Actions outages in the past two weeks."&lt;/p&gt;

&lt;p&gt;The same day, Tangled published &lt;a href="https://tangled.org/blog/forge/" rel="noopener noreferrer"&gt;The federated code forge&lt;/a&gt;, arguing that relying on someone else for your uptime "hasn't played out well." I don't think the timing was planned. It still works as an ad.&lt;/p&gt;

&lt;p&gt;My take: don't move everything off GitHub because of one bad Friday afternoon. Spend an hour this week mirroring your repos to Codeberg, a self-hosted Forgejo or Tangled, so the next outage costs you 10 minutes instead of a lost evening. Below: a status check you can alias, four ways to mirror, the popular setup that fails during an outage, and a CI fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: check if GitHub is down before you debug your own pipeline
&lt;/h2&gt;

&lt;p&gt;I've seen people (fine, I've been one of them) rerun a stuck workflow four times, roll back a commit and blame a Dependabot bump before checking whether GitHub itself is broken. GitHub's status page has a public JSON API, so make the check a shell alias:&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;alias &lt;/span&gt;&lt;span class="nv"&gt;ghdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'curl -s https://www.githubstatus.com/api/v2/components.json \
  | jq -r ".components[] | select(.status != \"operational\") | \"\(.name): \(.status)\""'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints &lt;code&gt;Actions: degraded_performance&lt;/code&gt;, close the laptop. To list active incidents, use &lt;code&gt;https://www.githubstatus.com/api/v2/incidents/unresolved.json&lt;/code&gt;. The &lt;a href="https://www.githubstatus.com/api" rel="noopener noreferrer"&gt;full endpoint list&lt;/a&gt; also covers summary, scheduled maintenance and incident history.&lt;/p&gt;

&lt;p&gt;One caveat from the HN thread: people noticed the 30-day uptime bar staying green during acknowledged incidents, because partial degradation isn't counted as downtime. One commenter mentioned that a third-party status page had to be built to track Actions accurately. So a green bar on the status page tells you very little.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: mirroring GitHub with a GitHub Action
&lt;/h2&gt;

&lt;p&gt;The most common mirror setup I see is a workflow that runs &lt;code&gt;git push --mirror&lt;/code&gt; to Codeberg on every push. It works fine until Actions goes down, and then the mirror stops updating at exactly the moment you need it.&lt;/p&gt;

&lt;p&gt;Your mirror has to be updated by something that doesn't depend on GitHub's job queue. That leaves two options: your own machine, or a forge that pushes or pulls on its own schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: one &lt;code&gt;git push&lt;/code&gt;, two forges
&lt;/h2&gt;

&lt;p&gt;This is the cheapest setup and needs no infrastructure. Git lets one remote have several push URLs:&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;# keep fetching from GitHub, but push everywhere&lt;/span&gt;
git remote set-url &lt;span class="nt"&gt;--add&lt;/span&gt; &lt;span class="nt"&gt;--push&lt;/span&gt; origin git@github.com:you/project.git
git remote set-url &lt;span class="nt"&gt;--add&lt;/span&gt; &lt;span class="nt"&gt;--push&lt;/span&gt; origin git@codeberg.org:you/project.git
git remote set-url &lt;span class="nt"&gt;--add&lt;/span&gt; &lt;span class="nt"&gt;--push&lt;/span&gt; origin git@tangled.org:you.tngl.sh/project

git remote &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="c"&gt;# origin  git@github.com:you/project.git (fetch)&lt;/span&gt;
&lt;span class="c"&gt;# origin  git@github.com:you/project.git (push)&lt;/span&gt;
&lt;span class="c"&gt;# origin  git@codeberg.org:you/project.git (push)&lt;/span&gt;
&lt;span class="c"&gt;# origin  git@tangled.org:you.tngl.sh/project (push)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;--add --push&lt;/code&gt; replaces the implicit push URL, which is why GitHub has to be listed explicitly. From then on, every &lt;code&gt;git push&lt;/code&gt; goes to all three. You need to create the empty repos on Codeberg and Tangled first.&lt;/p&gt;

&lt;p&gt;The weakness is that it only mirrors what you push from that clone. Merges done through the GitHub UI won't show up until someone pulls and pushes again. For a solo project that's fine. For a team, use option 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Codeberg, which only takes pushed mirrors
&lt;/h2&gt;

&lt;p&gt;Codeberg runs Forgejo, and Forgejo supports pull mirrors. Codeberg.org doesn't allow them, though. Their FAQ says that "mirrors that pull content from other code hosting services were problematic for Codeberg" and suggests manual mirrors with multiple remotes and &lt;code&gt;git push --mirror&lt;/code&gt; instead (&lt;a href="https://docs.codeberg.org/getting-started/faq/" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;). That's option 1 again, and I think it's a reasonable rule for a nonprofit that pays for its own disks.&lt;/p&gt;

&lt;p&gt;If the Codeberg copy is a backup and nothing more, turn off issues and PRs in the repo settings so nobody files bugs in the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: self-hosted Forgejo pull mirror, then fan out
&lt;/h2&gt;

&lt;p&gt;If you have a $5 VPS or a homelab box, this is the setup I'd pick, because it's the only one here that catches merges done in the GitHub UI. On your own Forgejo instance, create the repo with &lt;strong&gt;New Migration&lt;/strong&gt; and check &lt;strong&gt;This repository will be a mirror&lt;/strong&gt;. Forgejo then pulls from GitHub on a schedule, including changes made through the GitHub web UI.&lt;/p&gt;

&lt;p&gt;You can then fan out from there: &lt;strong&gt;Settings &amp;gt; Repository &amp;gt; Mirror Settings &amp;gt; Add Push Mirror&lt;/strong&gt; pushes to Codeberg, Tangled or GitLab. For GitLab, the &lt;a href="https://forgejo.org/docs/latest/user/repo-mirror/" rel="noopener noreferrer"&gt;Forgejo docs&lt;/a&gt; say to use &lt;code&gt;oauth2&lt;/code&gt; as the username and a token with the &lt;code&gt;write_repository&lt;/code&gt; scope. SSH push mirrors generate an Ed25519 key that you add as a deploy key on the target.&lt;/p&gt;

&lt;p&gt;Two warnings from those docs: push mirrors are forced and overwrite the remote, and LFS over SSH isn't implemented, so an SSH push mirror won't carry your LFS objects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4: Tangled, the federated forge on ATProto
&lt;/h2&gt;

&lt;p&gt;Tangled is built on the AT Protocol, the same protocol Bluesky runs on. Repos live on "knots," which you can host yourself or leave on tangled.org. CI runs on "spindles." The October 30 release added knot2, a Rust rewrite of the knot server, and moved issues, PRs, comments, reactions and labels onto the knot as collaborative objects, each one "just a chain of Git objects in the repo itself." That matters for mirroring: if your knot holds the issues too, your backup carries the issue and PR discussion along with the commits. The blog's pitch is "Whatever you choose, you can still talk to everyone else."&lt;/p&gt;

&lt;p&gt;Pushing to it is plain SSH. Add your key under Settings &amp;gt; Keys, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; tangled.org
  &lt;span class="k"&gt;Hostname&lt;/span&gt; tangled.org
  &lt;span class="k"&gt;User&lt;/span&gt; git
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519
  &lt;span class="k"&gt;AddressFamily&lt;/span&gt; inet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a backup, the gaps count for more than the features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Private repos aren't available yet. A co-founder said on HN they're rolling out "in the following couple weeks," and another team member said they have a working demo. Until they ship, Tangled only works for public code.&lt;/li&gt;
&lt;li&gt;HTTPS push and clone came up because people want knots behind Cloudflare. A team member replied that HTTPS push already works but isn't documented yet, so expect to figure it out yourself.&lt;/li&gt;
&lt;li&gt;On Windows, one commenter couldn't clone a repo with a colon in a filename. That's a Windows limit, not a Tangled bug, but it'll bite you the same way.&lt;/li&gt;
&lt;li&gt;Self-hosted knot operators have to upgrade to v1.20.0 for the new release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of &lt;a href="https://news.ycombinator.com/item?id=50305573" rel="noopener noreferrer"&gt;that thread&lt;/a&gt; is an argument about the protocol. One camp says ATProto "heavily lends itself to centralization." The other camp says relays are cheap and that ForgeFed, the ActivityPub approach Forgejo was working on, has been "in limbo forever." I lean toward Tangled here, but for a reason other than the protocol. A federated forge that ships stacked PRs today is more useful than a theoretically better protocol that hasn't shipped. Still, I wouldn't make a forge without private repos my only backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI fallback: Forgejo Actions, Woodpecker or Tangled spindles
&lt;/h2&gt;

&lt;p&gt;A mirror with no CI is a read-only archive. You don't need to port your whole pipeline, just enough to run tests and cut a release by hand.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Forgejo Actions&lt;/td&gt;
&lt;td&gt;Your runner, or Codeberg's &lt;a href="https://docs.codeberg.org/ci/actions/" rel="noopener noreferrer"&gt;limited hosted Actions&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.forgejo/workflows/*.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Designed to feel familiar to GitHub Actions users, though not compatible; disabled by default per repo on Codeberg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Woodpecker CI&lt;/td&gt;
&lt;td&gt;Codeberg's hosted instance (request access) or self-hosted&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.woodpecker.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What Codeberg's docs recommend for hosted CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tangled spindles&lt;/td&gt;
&lt;td&gt;Self-hosted runner, or third-party CI via &lt;a href="https://tangled.org/mitchellh.com/tack" rel="noopener noreferrer"&gt;tack&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.tangled/workflows/*.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nixery engine, deps from Nixpkgs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A minimal spindle workflow looks like this. The &lt;a href="https://tangled.org/j3s.sh/core/blob/ci/docs/spindle/pipeline.md" rel="noopener noreferrer"&gt;pipeline docs&lt;/a&gt; list &lt;code&gt;push&lt;/code&gt;, &lt;code&gt;pull_request&lt;/code&gt; and &lt;code&gt;manual&lt;/code&gt; as events, and &lt;code&gt;nixery&lt;/code&gt; as the only engine for now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;push"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pull_request"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nixery"&lt;/span&gt;

&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nixpkgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;go&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;go&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;test&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;./..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful part of the Forgejo runner is that it connects outbound to the forge, so a box behind NAT works. Codeberg's docs say it "does not need a public IP address." An old laptop in a closet is enough to unblock a hotfix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-hour checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Add the &lt;code&gt;ghdown&lt;/code&gt; alias, or a bookmark to &lt;code&gt;https://www.githubstatus.com/api/v2/summary.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create empty repos on Codeberg and/or Tangled.&lt;/li&gt;
&lt;li&gt;Add them as extra push URLs on &lt;code&gt;origin&lt;/code&gt;, or set up a pull mirror on your own Forgejo.&lt;/li&gt;
&lt;li&gt;Make sure the mirror doesn't depend on GitHub Actions.&lt;/li&gt;
&lt;li&gt;Copy your test job to one fallback CI and run it once, so you know it works before you need it.&lt;/li&gt;
&lt;li&gt;Write down which secrets the release job needs and where they'd live on the backup forge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'm not leaving GitHub. It's still where people find your project, and it will probably stay that way. I just want "GitHub is down" to mean I push somewhere else for 90 minutes and keep working.&lt;/p&gt;

&lt;p&gt;So: do you keep a mirror of your repos anywhere besides GitHub, or are you all in? And if you've tried Tangled's ATProto setup, is it federation or a nicer-looking single point of failure?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.githubstatus.com/incidents/pwxsjgnvrk6b" rel="noopener noreferrer"&gt;GitHub Status: Incident with Actions, Pages, and Webhooks&lt;/a&gt; and &lt;a href="https://news.ycombinator.com/item?id=50307723" rel="noopener noreferrer"&gt;HN discussion&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.githubstatus.com/api" rel="noopener noreferrer"&gt;GitHub Status API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tangled.org/blog/forge/" rel="noopener noreferrer"&gt;Tangled: The federated code forge&lt;/a&gt; and &lt;a href="https://news.ycombinator.com/item?id=50305573" rel="noopener noreferrer"&gt;HN discussion&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tangled.org/j3s.sh/core/blob/ci/docs/spindle/pipeline.md" rel="noopener noreferrer"&gt;Tangled spindle pipeline docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forgejo.org/docs/latest/user/repo-mirror/" rel="noopener noreferrer"&gt;Forgejo: Repository mirrors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.codeberg.org/getting-started/faq/" rel="noopener noreferrer"&gt;Codeberg FAQ&lt;/a&gt; and &lt;a href="https://docs.codeberg.org/ci/actions/" rel="noopener noreferrer"&gt;Codeberg: Forgejo Actions&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>git</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Claude MCP Server Connection Timeouts: Fixing the 30,000ms Startup Limit</title>
      <dc:creator>The Dollar Craft</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/thedollarcraft/claude-mcp-server-connection-timeouts-fixing-the-30000ms-startup-limit-3438</link>
      <guid>https://dev.to/thedollarcraft/claude-mcp-server-connection-timeouts-fixing-the-30000ms-startup-limit-3438</guid>
      <description>&lt;p&gt;If you run Claude Desktop with Model Context Protocol servers under heavy production loads, you hit a silent wall. Operations freeze, background jobs drop, and connections fail with cryptic errors.&lt;/p&gt;

&lt;p&gt;The issue is not your database or server logic. It is a hardcoded default inside Claude Desktop. The application enforces a strict 30,000 millisecond timeout for startup handshakes.&lt;/p&gt;

&lt;p&gt;When an MCP server takes longer than 30 seconds to boot, initialize dependencies, or parse configuration files, Claude terminates the process.&lt;/p&gt;

&lt;p&gt;Here is the technical walkthrough, architectural breakdown, and code fixes to eliminate these connection timeouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30,000ms Bottleneck: Why Claude MCP Drops Long-Running Connections
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffv8yzjxdgxkmk8upt071.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffv8yzjxdgxkmk8upt071.webp" alt=" " width="800" height="451"&gt;&lt;/a&gt;&lt;br&gt;
MCP does not run like standard HTTP APIs. It relies on persistent STDIO or SSE channels to pass tools and prompts between the client and local processes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Default Handshake Limits in Claude Desktop
&lt;/h3&gt;

&lt;p&gt;Claude Desktop allocates 30 seconds for the initial connection handshake. During this window, your server must initialize stdout and stdin channels, respond to the initialize JSON-RPC method call, and return available capabilities.&lt;/p&gt;

&lt;p&gt;If your script performs database connection pooling, fetches remote secrets, or loads heavy libraries during startup, it hits the timeout limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-Step Fix: Overriding Default Timeout Settings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Method 1: Modifying Environment Variables in claude_desktop_config.json
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6d7jdj8dq6rvqfwi2kle.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6d7jdj8dq6rvqfwi2kle.webp" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
To override connection timeout thresholds, pass explicit environment variables directly within your JSON configuration file.&lt;/p&gt;

&lt;p&gt;Open your claude_desktop_config.json file located at APPDATA\Claude\claude_desktop_config.json on Windows or Library/Application Support/Claude/claude_desktop_config.json on macOS.&lt;/p&gt;

&lt;p&gt;Add scoped server timeouts directly inside the env object:&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "mcpServers": {&lt;br&gt;
    "production-data-ingest": {&lt;br&gt;
      "command": "node",&lt;br&gt;
      "args": ["C:/mcp-servers/data-ingest/dist/server.js"],&lt;br&gt;
      "env": {&lt;br&gt;
        "MCP_TIMEOUT_MS": "120000",&lt;br&gt;
        "NODE_OPTIONS": "--max-old-space-size=4096"&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 2: Adjusting Client-Side Call Parameters in Python
&lt;/h3&gt;

&lt;p&gt;If you invoke MCP servers programmatically using Python, the default ClientSession timeout drops long-running tool executions. Pass explicit timedelta values to read_timeout_seconds when initializing the session.&lt;/p&gt;

&lt;p&gt;import asyncio&lt;br&gt;
from datetime import timedelta&lt;br&gt;
from mcp import ClientSession, StdioServerParameters&lt;br&gt;
from mcp.client.stdio import stdio_client&lt;/p&gt;

&lt;p&gt;server_params = StdioServerParameters(&lt;br&gt;
    command="python",&lt;br&gt;
    args=["heavy_worker.py"]&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;async def run_pipeline():&lt;br&gt;
    async with stdio_client(server_params) as (read, write):&lt;br&gt;
        async with ClientSession(&lt;br&gt;
            read, &lt;br&gt;
            write, &lt;br&gt;
            read_timeout_seconds=timedelta(seconds=120)&lt;br&gt;
        ) as session:&lt;br&gt;
            await session.initialize()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        result = await session.call_tool(
            "process_large_dataset",
            arguments={"batch_size": 5000},
            read_timeout_seconds=timedelta(seconds=180)
        )
        print(result)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    asyncio.run(run_pipeline())&lt;/p&gt;

&lt;h2&gt;
  
  
  Advanced Engineering Fixes for Production Pipelines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Implementing Asynchronous Handshakes and Exponential Backoff
&lt;/h3&gt;

&lt;p&gt;In distributed production setups, immediate retries cause thundering herd problems when an MCP server experiences latency. Implement asynchronous backoff loops with random jitter.&lt;/p&gt;

&lt;p&gt;import asyncio&lt;br&gt;
import random&lt;br&gt;
import logging&lt;br&gt;
from datetime import timedelta&lt;br&gt;
from mcp import ClientSession, StdioServerParameters&lt;br&gt;
from mcp.client.stdio import stdio_client&lt;br&gt;
from mcp.shared.exceptions import McpError&lt;/p&gt;

&lt;p&gt;logging.basicConfig(level=logging.INFO)&lt;br&gt;
logger = logging.getLogger("mcp-resilience")&lt;/p&gt;

&lt;p&gt;async def connect_with_retry(max_retries=5, base_delay=2.0):&lt;br&gt;
    server_params = StdioServerParameters(&lt;br&gt;
        command="node", &lt;br&gt;
        args=["dist/index.js"]&lt;br&gt;
    )&lt;/p&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for attempt in range(max_retries):&lt;br&gt;
    try:&lt;br&gt;
        async with stdio_client(server_params) as (read, write):&lt;br&gt;
            async with ClientSession(&lt;br&gt;
                read, &lt;br&gt;
                write, &lt;br&gt;
                read_timeout_seconds=timedelta(seconds=90)&lt;br&gt;
            ) as session:&lt;br&gt;
                await session.initialize()&lt;br&gt;
                logger.info("Successfully established MCP session.")&lt;br&gt;
                return session&lt;br&gt;
    except (McpError, ConnectionError, TimeoutError) as err:&lt;br&gt;
        if attempt == max_retries - 1:&lt;br&gt;
            logger.error("Max connection attempts reached.")&lt;br&gt;
            raise err
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    sleep_duration = (base_delay ** attempt) + random.uniform(0.1, 0.5)
    logger.warning(f"Attempt failed: {err}. Retrying in {sleep_duration:.2f}s...")
    await asyncio.sleep(sleep_duration)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h2&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  Final Technical Takeaways&lt;br&gt;
&lt;/h2&gt;

&lt;p&gt;Startup handshakes and tool executions fail for different architectural reasons. Fix the startup routine first.&lt;/p&gt;

&lt;p&gt;Never run database schema migrations or heavy library imports during the initial process boot sequence.&lt;/p&gt;

&lt;p&gt;Always use absolute paths when targeting runtimes like node or python on Windows platforms.&lt;/p&gt;

&lt;p&gt;Implement client-side exponential backoff loops to recover from transient connection drops gracefully.&lt;/p&gt;

&lt;p&gt;🚹Author Bio:&lt;br&gt;
The Dollar Craft is an independent AI research publication documenting B2B automation architectures, private local RAG deployments, and no-code data infrastructure for legal and financial firms across North America and Europe. We specialize in analyzing compliance-first AI integrations, self-hosted n8n workflows, and data-backed systems for modern enterprises.&lt;/p&gt;

&lt;p&gt;⚖️&amp;gt; &lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is for educational and informational purposes only. The views expressed are my own and do not reflect the official stance of any vendor or tool mentioned. Test all configurations and scripts in a staging environment before running them in production.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>claude</category>
      <category>performance</category>
      <category>backend</category>
    </item>
    <item>
      <title>Software Supply Chain Security for Small Teams</title>
      <dc:creator>Norbert E. Boros</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/mecanik-dev/software-supply-chain-security-for-small-teams-3e65</link>
      <guid>https://dev.to/mecanik-dev/software-supply-chain-security-for-small-teams-3e65</guid>
      <description>&lt;p&gt;Supply chain security sounds like a problem for organisations with a security function, and the framing is misleading. A small team running a handful of services typically depends on hundreds of packages it has never read, pulled at build time from registries it does not control, executing installation scripts on machines that hold its deployment credentials.&lt;/p&gt;

&lt;p&gt;The exposure is not proportional to company size. It is proportional to dependency count and build automation, and small teams often have more of the first and less scrutiny of the second than the organisations that worry about this publicly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The uncomfortable arithmetic:&lt;/strong&gt; your application has perhaps a dozen direct dependencies and several hundred transitive ones. You chose the dozen. You did not choose the rest, you have not read any of them, and any one of them running an install script has the same access as your build process. That is the actual attack surface, and it is not visible from the dependency list you wrote.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Supply Chain Security Starts at Install Time
&lt;/h2&gt;

&lt;p&gt;The dangerous moment is usually not running the code. It is installing it.&lt;/p&gt;

&lt;p&gt;Package managers permit scripts to execute during installation, which means a compromised or malicious package runs with the privileges of whoever ran the install. On a developer machine that is their credentials. In a build pipeline it is the deployment keys, which is worse.&lt;/p&gt;

&lt;p&gt;Three attack shapes account for most real incidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typosquatting.&lt;/strong&gt; A package with a name one character from a popular one, published and waiting for a mistyped install command. Cheap to execute and effective at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account compromise of a legitimate package.&lt;/strong&gt; A widely used package whose maintainer's credentials were stolen, updated with something harmful. This is the hardest to defend against because nothing about the name or the download count looks wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependency confusion.&lt;/strong&gt; A public package published under the name of one of your internal ones, where a misconfigured resolver prefers the public registry. Entirely a configuration problem and entirely preventable.&lt;/p&gt;

&lt;p&gt;None of these require anyone to target you specifically, which is the point. They are opportunistic and they scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls That Repay the Effort at Small Scale
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit the lockfile and install from it.&lt;/strong&gt; A lockfile pins exact versions and their hashes. Installing from the manifest instead resolves fresh versions at build time, which means your build is not reproducible and an upstream change reaches production without review. Use the install command that fails when the lockfile disagrees rather than the one that updates it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disable install scripts where you can.&lt;/strong&gt; Many ecosystems support this, and most packages do not need them. It is the single largest reduction in exposure available, and the packages that break are worth knowing about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automate vulnerability alerts, then triage.&lt;/strong&gt; Scanners produce more findings than a small team can act on, and the failure mode is not missing alerts, it is ignoring all of them because most are irrelevant. Filter to what is reachable from your code and act on that. A queue nobody reads is worse than no queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin your build tooling too.&lt;/strong&gt; The container image, the runtime version and the action or plugin your pipeline calls are all dependencies, and referencing a mutable tag means your build changes without you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate build credentials from everything else.&lt;/strong&gt; A pipeline that can deploy should not hold credentials that can do anything more, and a compromised build should not be able to reach production data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an SBOM Does and Does Not Tell You
&lt;/h2&gt;

&lt;p&gt;A software bill of materials lists what is in your application, and its value is answering one question fast: are we affected by this?&lt;/p&gt;

&lt;p&gt;That question used to take days. When a widely used component turns out to be vulnerable, an organisation with a current inventory answers in minutes and one without it spends a week searching. That difference is the whole argument, and it is why &lt;a href="https://www.cisa.gov/sbom" rel="noopener noreferrer"&gt;SBOM adoption is being pushed&lt;/a&gt; as baseline practice rather than as an advanced control.&lt;/p&gt;

&lt;p&gt;What it does not do is make you secure. An inventory is a list, not a defence. It does not tell you whether a listed vulnerability is reachable in your code, whether it matters in your configuration, or whether anything has been compromised. Teams that generate one and file it have added a document rather than a control.&lt;/p&gt;

&lt;p&gt;Generate it from the build, so it describes what was actually shipped rather than what the manifest claimed. Keep the one matching each release. And accept that its value is entirely in the speed of answering a question you will be asked under pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Small Teams Actually Get Hurt
&lt;/h2&gt;

&lt;p&gt;Not usually by an exotic package compromise. By ordinary things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets in the repository&lt;/strong&gt;, which remain in history after deletion and are found by automated scanners within minutes of a push. &lt;strong&gt;A dependency three years out of date&lt;/strong&gt; with a published exploit and no upgrade path, because upgrading was deferred until it became a project. &lt;strong&gt;Build pipelines with excessive permissions&lt;/strong&gt;, where a pull request from a fork can run with access it should not have. &lt;strong&gt;Nobody watching&lt;/strong&gt;, so a compromise persists because no alert reaches a person.&lt;/p&gt;

&lt;p&gt;The unglamorous conclusion is that keeping dependencies current is most of supply chain security in practice. The &lt;a href="https://mecanik.dev/en/posts/software-maintenance-cost/" rel="noopener noreferrer"&gt;software maintenance cost&lt;/a&gt; that teams treat as optional is the same work, and deferring it converts a routine upgrade into an emergency with an exploit attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Proportionate Starting Point
&lt;/h2&gt;

&lt;p&gt;For a small team, in order: get secrets out of the repository and rotate anything exposed. Commit lockfiles and install from them strictly. Turn on dependency alerts and triage weekly rather than continuously. Pin build tooling to immutable references. Reduce pipeline permissions to the minimum. Generate an SBOM per release once the rest is in place.&lt;/p&gt;

&lt;p&gt;That sequence covers the realistic threats without a security function, and each step is hours rather than weeks. The controls beyond it, provenance attestation, reproducible builds, signed artefacts, are genuinely valuable and belong to a stage where the basics are already stable.&lt;/p&gt;

&lt;p&gt;Mecanik reviews and hardens build pipelines as part of our &lt;a href="https://mecanik.dev/en/services/application-security/" rel="noopener noreferrer"&gt;application security&lt;/a&gt; work. The finding is almost never a sophisticated compromise. It is a token in a repository and a dependency nobody has updated since it was chosen.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Related reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mecanik.dev/en/posts/password-storage/" rel="noopener noreferrer"&gt;Password Storage: What to Use in 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mecanik.dev/en/posts/wordpress-security-hardening-checklist-2026/" rel="noopener noreferrer"&gt;WordPress Security Hardening Checklist for 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mecanik.dev/en/posts/uptime-sla/" rel="noopener noreferrer"&gt;Uptime SLAs That Mean Something&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mecanik.dev/en/posts/api-security-protect-public-api/" rel="noopener noreferrer"&gt;API Security: How to Protect a Public API in 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why is installing packages the risky moment?&lt;/strong&gt;&lt;br&gt;
Because package managers allow scripts to run during installation, with the privileges of whoever ran the install. On a developer machine that means their credentials; in a build pipeline it means deployment keys. The code does not have to be executed by your application to do damage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is dependency confusion?&lt;/strong&gt;&lt;br&gt;
An attack where a public package is published under the name of one of your internal packages, and a misconfigured resolver prefers the public registry over your private one. It requires nobody to target you specifically and is entirely preventable through resolver configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does an SBOM actually give you?&lt;/strong&gt;&lt;br&gt;
A fast answer to one question: are we affected by this? With a current inventory that takes minutes; without one it takes a week of searching. It does not tell you whether a listed vulnerability is reachable in your code or whether anything has been compromised, so an SBOM that is generated and filed is a document rather than a control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I commit my lockfile?&lt;/strong&gt;&lt;br&gt;
Yes, and install from it strictly using the command that fails when the lockfile disagrees rather than the one that updates it. Installing from the manifest resolves fresh versions at build time, which makes builds unreproducible and lets an upstream change reach production without any review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should a small team start?&lt;/strong&gt;&lt;br&gt;
Remove secrets from the repository and rotate anything exposed, commit lockfiles and install strictly, enable dependency alerts and triage them weekly, pin build tooling to immutable references, reduce pipeline permissions, then generate an SBOM per release. Each step is hours rather than weeks.&lt;/p&gt;

</description>
      <category>security</category>
      <category>cybersecurity</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building hldays #7: Religious and cultural observances</title>
      <dc:creator>Alexander Denisov</dc:creator>
      <pubDate>Sat, 31 Oct 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/altofire/building-hldays-7-religious-and-cultural-observances-2aa6</link>
      <guid>https://dev.to/altofire/building-hldays-7-religious-and-cultural-observances-2aa6</guid>
      <description>&lt;p&gt;A public holiday is a legal fact: the law says the office is closed. A religious observance is something else. Ramadan, Passover or Lunar New Year don't close anything in Lisbon, but a lot of people still plan around them: shift rotas, delivery windows, a marketing calendar, a meeting you shouldn't put on Yom Kippur.&lt;/p&gt;

&lt;p&gt;So hldays has a second layer next to holidays: observances. There are 46 of them across six traditions (Western Christian, Orthodox Christian, Jewish, Islamic, Chinese and Buddhist), served by &lt;code&gt;/v1/observances&lt;/code&gt; and attached to the date endpoints on paid plans.&lt;/p&gt;

&lt;p&gt;I added them for exactly that kind of scheduling. The API already knew when offices close, and it felt incomplete not to know when a lot of people are busy anyway.&lt;/p&gt;

&lt;p&gt;This post is mostly about the calendars, because that's where the interesting problems were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data first
&lt;/h2&gt;

&lt;p&gt;Each tradition is one YAML file in &lt;code&gt;data/observances/&lt;/code&gt;. An observance looks a lot like a holiday rule, with three extra ideas: a span (&lt;code&gt;days&lt;/code&gt; or an &lt;code&gt;end&lt;/code&gt; rule), an evening start (&lt;code&gt;eve&lt;/code&gt;), and whether the date is a calculation that might be off (&lt;code&gt;estimated&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;passover&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;en&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Passover"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;he&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;פסח"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;calendar_date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;calendar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;hebrew&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;month&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Nisan&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;15&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;
  &lt;span class="na"&gt;eve&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;days&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;diaspora,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Israel."&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule kinds are the same ones the holiday engine uses (&lt;code&gt;src/core/dates.ts&lt;/code&gt;), so an observance can be a fixed date, an offset from Easter, an offset from Orthodox Easter, or a day in another calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Easter: the one with an algorithm
&lt;/h2&gt;

&lt;p&gt;Western Easter is the anonymous Gregorian algorithm, straight from the textbook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Gregorian Easter Sunday (anonymous Gregorian algorithm). */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;easterSunday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... eleven more lines of integer arithmetic&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;31&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&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="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything movable in the Western calendar is an offset from it: Ash Wednesday is &lt;code&gt;{ easter: -46 }&lt;/code&gt;, Pentecost &lt;code&gt;{ easter: 49 }&lt;/code&gt;. The same function already drives holidays like Good Friday and Corpus Christi in the national data, so observances got it for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orthodox Easter: same idea, different calendar
&lt;/h2&gt;

&lt;p&gt;Orthodox churches compute Easter on the Julian calendar. The code uses Meeus' Julian algorithm and then shifts the result onto the Gregorian calendar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Orthodox Easter Sunday as a Gregorian date (Meeus' Julian algorithm + Julian→Gregorian shift; valid 1900–2099). */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;orthodoxEasterSunday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;31&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 13 days in 1900–2099&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&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="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;shift&lt;/code&gt; line is the bit worth noticing: the gap between the two calendars grows by a day in most century years, and it's 13 days for the whole range hldays supports (2016 to 2099). The test includes the year both Easters coincide, which is a good sanity check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;orthodoxEasterSunday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-04-20&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same day as Western Easter&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;orthodoxEasterSunday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-04-12&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;expandRule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;orthodox_easter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-02-23&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// Greek Clean Monday&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The YAML also notes a split inside Orthodoxy: Julian-calendar churches celebrate Christmas on 7 January, while Revised Julian churches (Greek, Romanian, Bulgarian) use 25 December but share the Orthodox Easter cycle. That's a note on the record, not two records.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hebrew and Islamic dates: let Intl do it
&lt;/h2&gt;

&lt;p&gt;I didn't want to ship conversion tables for every calendar. JavaScript's &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; already knows the Hebrew and Islamic calendars (through ICU), so the engine builds an index for a Gregorian year by formatting every day of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`en-u-ca-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UTC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// Intl silently falls back to Gregorian when a runtime lacks the calendar; wrong dates are worse than an error.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;calendar&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Intl calendar &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not supported`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line is my favourite in the file. If a runtime doesn't support a calendar, &lt;code&gt;Intl&lt;/code&gt; doesn't throw: it quietly formats Gregorian dates instead. Without the check, a runtime without full ICU would treat Islamic month 10, day 1 as 1 October and put Eid al-Fitr there. I checked that the output on Cloudflare Workers matches Node before relying on it.&lt;/p&gt;

&lt;p&gt;The Hebrew calendar had one naming trap. In leap years there are two Adars, and Intl calls the second one "Adar II". Purim is in Adar II in leap years, so the index stores "Adar II" under "Adar":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parts&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;month&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^Adar II$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Adar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;


&lt;span class="c1"&gt;// "Adar" is Adar II in leap years (Purim)&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;expandRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;purim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2024-03-24&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;expandRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;purim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-03-14&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Islamic dates are estimates, and the API says so
&lt;/h2&gt;

&lt;p&gt;The Islamic calendar is lunar, and in many countries a month starts when the new moon is actually &lt;em&gt;sighted&lt;/em&gt;. No algorithm can know that in advance. hldays calculates with the Umm al-Qura calendar (Saudi Arabia's), and the YAML says up front what that means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Islamic observances, calculated with the Umm al-Qura calendar (Saudi Arabia). Many countries start months on
# a local moon sighting, so the observed date can differ by a day: every entry is marked estimated.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Islamic observance has &lt;code&gt;estimated: true&lt;/code&gt;, and it comes through in the response so clients can show "around" instead of a firm date. Here's Eid al-Fitr on a business-day call (paid plan, run locally):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jurisdiction"&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PT-M-1106"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Lisboa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&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="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"business_day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"holidays"&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="nl"&gt;"observances"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"observance_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eid-al-fitr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tradition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"islamic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&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="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Eid al-Fitr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ar"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"starts_previous_evening"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"estimated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &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="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;&lt;code&gt;starts_previous_evening&lt;/code&gt; comes from &lt;code&gt;eve: true&lt;/code&gt;: Jewish and Islamic days begin at sunset, so Eid "on" 20 March starts on the evening of the 19th.&lt;/p&gt;

&lt;p&gt;A lunar year is about 11 days shorter than a Gregorian one, which breaks a quiet assumption: that a date happens once a year. Eid al-Fitr happens twice in 2033. The rule expander returns a list for that reason, and the test makes it explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The Islamic year is ~11 days shorter, so some Gregorian years hold the same date twice&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;expandRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2033&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Spans that cross New Year
&lt;/h2&gt;

&lt;p&gt;Ramadan doesn't have a fixed length: it's 29 or 30 days. So instead of &lt;code&gt;days&lt;/code&gt;, it has an &lt;code&gt;end&lt;/code&gt; rule: the day before 1 Shawwal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ramadan&lt;/span&gt;
  &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;calendar_date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;calendar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;islamic-umalqura&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;month&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;9&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;calendar_date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;calendar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;islamic-umalqura&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;month&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;10&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;day&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;offset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;-1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around 2030 Ramadan starts in December and ends in January. Asking "what's observed on 5 January 2031?" has to find a span that &lt;em&gt;started the previous year&lt;/em&gt;, so &lt;code&gt;Observances.between&lt;/code&gt; always looks one year back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A span can start in the previous Gregorian year (Ramadan across New Year).&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&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="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;


&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2031-01-05&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;observance_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ramadan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2030-12-26&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2031-01-23&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;estimated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Chinese calendar: where Intl was wrong
&lt;/h2&gt;

&lt;p&gt;I started with Intl for the Chinese calendar too. Then I compared it with the Hong Kong Observatory's published tables and found that ICU gets 5 of the 1039 month starts between 2016 and 2099 wrong by a day. Two of them are Chinese New Year: 2027 and 2030.&lt;/p&gt;

&lt;p&gt;You can see it in Node (ICU 78.3):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2027-02-06 12/30 &amp;lt;- ICU: last day of the 12th month
2027-02-07 1/1 &amp;lt;- ICU: New Year
2030-02-02 1/1 &amp;lt;- ICU: New Year

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HKO tables say 6 February 2027 and 3 February 2030. Being a day off on the biggest date in the calendar wasn't acceptable, so for the Chinese calendar only, hldays uses HKO's data. &lt;code&gt;scripts/import-chinese-calendar.ts&lt;/code&gt; downloads one text file per year and keeps just the month starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d{4})\/(\d{1,2})\/(\d{1,2})\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(\d{1,2})(?:&lt;/span&gt;&lt;span class="sr"&gt;st|nd|rd|th&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt; Lunar Month/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// HKO repeats the month number for a leap month&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;leap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prevMonth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;starts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;leap&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is &lt;code&gt;src/core/chinese-months.json&lt;/code&gt;: 1077 entries like &lt;code&gt;"2014-01-31 1"&lt;/code&gt;, from 2014 to 2100. Leap months are marked &lt;code&gt;L&lt;/code&gt; and never match a rule, so "2nd month, day 1" in 2023 is the regular second month and not the leap one that follows it. The tests check the exact days ICU gets wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 2027 and 2030 are days ICU's Chinese calendar gets wrong; the engine uses the HKO table&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2027&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2030&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;expandRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-01-29&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-02-17&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2027-02-06&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2030-02-03&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table starts in 2014, not 2016, and git history explains why. The first version guarded the range strictly and rejected 2014, but &lt;code&gt;/v1/observances?year=2016&lt;/code&gt; needs it: observances always look one year back (for spans like Ramadan), and Lunar New Year's Eve is "1st month, day 1, offset -1", where an offset makes the engine expand the neighbouring years too. 2016 reaches 2015, and 2015 reaches 2014. A test now asks for the first and last day of the supported range, so that can't come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observances never affect business days
&lt;/h2&gt;

&lt;p&gt;This was a design rule from the first commit of the feature: observances are information, not days off. The business-day code doesn't read them at all. &lt;code&gt;isBusinessDay&lt;/code&gt; looks at the weekend and at holidays, and observances are attached to the response afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isBusinessDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;types&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;municipal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;jurisdiction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;holidays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;withSources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;holidays&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;observancesOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where an observance &lt;em&gt;is&lt;/em&gt; a day off, like Good Friday in Portugal, it's also a holiday in that country's data, with a source. The observance list just tells you it's Good Friday; the holiday list tells you the office is closed. Mixing the two would mean Eid closes Lisbon, which it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plans and limits
&lt;/h2&gt;

&lt;p&gt;Observances are on the paid plans only. On the free plan, &lt;code&gt;/v1/observances&lt;/code&gt; returns 403 &lt;code&gt;paid_plan_required&lt;/code&gt;, and the date endpoints return &lt;code&gt;observances: []&lt;/code&gt; with an &lt;code&gt;observances_notice&lt;/code&gt; that says how many were hidden, so a free client still knows something is there.&lt;/p&gt;

&lt;p&gt;What isn't covered yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Theravada Vesak.&lt;/strong&gt; The Buddhist file has only the East Asian Buddha's Birthday; Vesak needs a lunar calendar the engine doesn't support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hindu and Sikh calendars.&lt;/strong&gt; Nothing yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regional variants.&lt;/strong&gt; Jewish festivals use diaspora lengths (Israel's are in &lt;code&gt;notes&lt;/code&gt;), Shia dates for Mawlid are a note, and Korea and Vietnam can differ from China by a day where their time zone does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which calendar would you want next: Hindu, Vesak, or something else? Tell me in the comments.&lt;/p&gt;

&lt;p&gt;Next in the series: making an API agent-friendly with MCP, and designing tools for Claude and other agents.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://hldays.dev" rel="noopener noreferrer"&gt;hldays.dev&lt;/a&gt; · &lt;a href="https://hldays.dev/docs" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>CDC Schema Changes: What Breaks and How to Ship Them</title>
      <dc:creator>Pipeline Whiteboard</dc:creator>
      <pubDate>Sat, 31 Oct 2026 18:58:27 +0000</pubDate>
      <link>https://dev.to/pipelinewhiteboard/cdc-schema-changes-what-breaks-and-how-to-ship-them-3678</link>
      <guid>https://dev.to/pipelinewhiteboard/cdc-schema-changes-what-breaks-and-how-to-ship-them-3678</guid>
      <description>&lt;p&gt;On Friday at 17:40, someone renamed one column in the &lt;code&gt;orders&lt;/code&gt; table. The pipeline stayed green all weekend. On Monday, the revenue dashboard showed zero for Saturday and Sunday. Nothing failed, and that was the problem.&lt;/p&gt;

&lt;p&gt;This is Part 5 of &lt;strong&gt;CDC in Production&lt;/strong&gt;. In &lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;Part 4&lt;/a&gt; the sink learned to survive duplicates, late events and deletes. This time the table itself changes. We'll follow a schema change through the pipeline, look at the registry rules that decide what's allowed, see what Snowflake and the lakehouse can absorb, and finish with a migration pattern that never breaks either side.&lt;/p&gt;

&lt;p&gt;Prefer to watch? Here's the 7-minute video version with animated diagrams:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/uhYmKOktRZ8" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Video link: &lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;CDC Schema Changes: What Breaks and How to Ship Them&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a schema change travels
&lt;/h2&gt;

&lt;p&gt;Postgres &lt;strong&gt;logical decoding&lt;/strong&gt; (the feature that turns the write-ahead log into row changes) doesn't carry DDL. So Debezium never sees the &lt;code&gt;ALTER TABLE&lt;/code&gt; itself. It notices the new shape on the &lt;em&gt;next row change&lt;/em&gt; for that table and refreshes its in-memory schema. That event goes out with the new structure.&lt;/p&gt;

&lt;p&gt;If you use Avro, the converter then registers the new structure as a new &lt;strong&gt;schema version&lt;/strong&gt; in &lt;strong&gt;Schema Registry&lt;/strong&gt;, a service that stores every version of each message schema. Versions are grouped under a &lt;strong&gt;subject&lt;/strong&gt;; with the default naming, the value schema for topic &lt;code&gt;shop.public.orders&lt;/code&gt; lives under &lt;code&gt;shop.public.orders-value&lt;/code&gt;. Before accepting a new version, the registry runs a compatibility check against what's already there.&lt;/p&gt;

&lt;p&gt;The converter is configured on the connector (or the Connect worker):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"value.converter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"io.confluent.connect.avro.AvroConverter"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"value.converter.schema.registry.url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://schema-registry:8081"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Registry compatibility modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Compatibility&lt;/strong&gt; is the rule the registry uses to decide whether a new version may be registered. The default is &lt;code&gt;BACKWARD&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Guarantee&lt;/th&gt;
&lt;th&gt;Upgrade first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BACKWARD&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Consumers using the new schema can read data written with the old one&lt;/td&gt;
&lt;td&gt;Consumers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FORWARD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Consumers using the old schema can read data written with the new one&lt;/td&gt;
&lt;td&gt;Producers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Both directions&lt;/td&gt;
&lt;td&gt;Either side, independently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By default the check compares only against the latest version. The &lt;code&gt;_TRANSITIVE&lt;/code&gt; variants (&lt;code&gt;BACKWARD_TRANSITIVE&lt;/code&gt; and so on) compare against every version, which matters if a consumer might replay old data from the start of a topic.&lt;/p&gt;

&lt;p&gt;What does that allow in practice, with Avro?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;&lt;code&gt;BACKWARD&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;FORWARD&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;FULL&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add an optional field (has a default)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove an optional field&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a required field (no default)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove a required field&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of it comes from one rule in Avro's schema resolution: if the reader's schema has a field with no default, and the data was written without that field, the read fails. With Debezium, a nullable Postgres column becomes an optional field with a &lt;code&gt;null&lt;/code&gt; default. A &lt;code&gt;NOT NULL&lt;/code&gt; column becomes a required field, unless Debezium can carry the column's default value into the schema (it does this for many defaults; for function defaults, see mistake 4 below).&lt;/p&gt;

&lt;h2&gt;
  
  
  The rename, two ways
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;amount_cents&lt;/code&gt; became &lt;code&gt;total_cents&lt;/code&gt;. To Debezium, that's one field gone and a new one added.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the column is &lt;code&gt;NOT NULL&lt;/code&gt;&lt;/strong&gt;, the new field is required, with no default. Under &lt;code&gt;BACKWARD&lt;/code&gt;, the registry rejects it. The converter throws, and with Kafka Connect's default &lt;code&gt;errors.tolerance=none&lt;/code&gt;, the connector task fails. That's loud, and loud is good: someone gets paged on Friday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the real incident, the column was nullable.&lt;/strong&gt; Both fields are optional, so the registry accepts the change and nothing turns red. Snowflake, with schema evolution switched on, adds a &lt;code&gt;TOTAL_CENTS&lt;/code&gt; column. The old &lt;code&gt;AMOUNT_CENTS&lt;/code&gt; column gets &lt;code&gt;NULL&lt;/code&gt; on every new row. Every dashboard still sums &lt;code&gt;AMOUNT_CENTS&lt;/code&gt;, so weekend revenue reads zero.&lt;/p&gt;

&lt;p&gt;A failed connector is the good outcome. The dangerous schema changes are the ones every system accepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Snowflake absorbs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Schema evolution&lt;/strong&gt; lets a load add columns to the target table automatically. It needs three things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ENABLE_SCHEMA_EVOLUTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- The loading role needs EVOLVE SCHEMA (or OWNERSHIP) on the table&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;EVOLVE&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a supported loading method: &lt;code&gt;COPY INTO&lt;/code&gt; (with &lt;code&gt;MATCH_BY_COLUMN_NAME&lt;/code&gt;), Snowpipe, or Snowpipe Streaming. Plain &lt;code&gt;INSERT&lt;/code&gt; can't evolve a table.&lt;/p&gt;

&lt;p&gt;And it does only two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it &lt;strong&gt;adds&lt;/strong&gt; new columns, and&lt;/li&gt;
&lt;li&gt;it &lt;strong&gt;drops &lt;code&gt;NOT NULL&lt;/code&gt;&lt;/strong&gt; from columns that are missing in new data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It never renames and never changes a type.&lt;/p&gt;

&lt;p&gt;Type changes through &lt;code&gt;ALTER TABLE ... ALTER COLUMN&lt;/code&gt; are stricter still:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Allowed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Make a &lt;code&gt;VARCHAR&lt;/code&gt; longer&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raise a number's precision&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change a number's scale&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make a &lt;code&gt;VARCHAR&lt;/code&gt; shorter&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn a number into a &lt;code&gt;VARCHAR&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So if Postgres moves &lt;code&gt;price&lt;/code&gt; from &lt;code&gt;numeric(10,2)&lt;/code&gt; to &lt;code&gt;numeric(12,4)&lt;/code&gt;, Snowflake can't follow in place. The scale changed. You add a new column, backfill it, and switch readers over, which is the pattern at the end of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lakehouse: more forgiving, not magic
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Apache Iceberg&lt;/strong&gt; tracks every column by a unique field ID, not by its name. Renames and drops are metadata changes, so no data files are rewritten. A new column gets a fresh ID, so it never reads old data by mistake, even if it reuses an old name. Type changes are limited to safe widening: &lt;code&gt;int&lt;/code&gt; to &lt;code&gt;long&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt; to &lt;code&gt;double&lt;/code&gt;, or a &lt;code&gt;decimal&lt;/code&gt; with more precision and the same scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delta Lake&lt;/strong&gt; can rename and drop columns only with &lt;strong&gt;column mapping&lt;/strong&gt; enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TBLPROPERTIES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.columnMapping.mode'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.minReaderVersion'&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'delta.minWriterVersion'&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a protocol upgrade you can't undo, and after a rename or drop it can break readers of the table's change data feed.&lt;/p&gt;

&lt;p&gt;Here's the catch for both formats: a CDC sink still sees a rename as a dropped field and a new one, because that's what the events say. Unless you rename the table column yourself, you get the same split as in Snowflake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand, then contract
&lt;/h2&gt;

&lt;p&gt;The safe pattern is &lt;strong&gt;expand/contract&lt;/strong&gt;: make the schema bigger first, move everything over, and only then make it smaller. A rename becomes four steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Expand: add the new column as nullable (or with a constant default)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Backfill in small batches, and have the application write both columns&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Move every reader (views, models, dashboards) to total_cents&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Contract, weeks later, once nothing reads the old column&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;amount_cents&lt;/code&gt; was nullable, every step only adds or removes an optional field, which the registry accepts in any mode. (Dropping a &lt;code&gt;NOT NULL&lt;/code&gt; column removes a required field, which passes &lt;code&gt;BACKWARD&lt;/code&gt; but not &lt;code&gt;FORWARD&lt;/code&gt; or &lt;code&gt;FULL&lt;/code&gt;.) Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The backfill is CDC traffic.&lt;/strong&gt; Every row it updates becomes a change event, which is why it runs in batches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake won't drop the old column for you.&lt;/strong&gt; Schema evolution never removes columns. Drop &lt;code&gt;AMOUNT_CENTS&lt;/code&gt; in the warehouse yourself once readers have moved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two guardrails
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test schemas in CI.&lt;/strong&gt; Schema Registry has a compatibility endpoint that tells you whether a schema would be accepted, without registering it:&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;# Wrap the new Avro schema in the request body the registry expects&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json, sys; print(json.dumps({"schema": open(sys.argv[1]).read()}))'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  orders-value.avsc &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; payload.json

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/vnd.schemaregistry.v1+json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; @payload.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'http://schema-registry:8081/compatibility/subjects/shop.public.orders-value/versions/latest?verbose=true'&lt;/span&gt;
&lt;span class="c"&gt;# {"is_compatible":false,"messages":[...]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail the build when &lt;code&gt;is_compatible&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. Remember that a nullable rename passes this check, so it catches only the loud kind of break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the warehouse.&lt;/strong&gt; Alert when a new column appears on a captured table, and when a column's null rate jumps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;DATE_TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&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;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rows_loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COUNT_IF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;null_rate&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DATEADD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="n"&gt;null_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold is yours to pick. Either alert would have caught Friday's rename on Friday night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Renaming a column with a single &lt;code&gt;ALTER&lt;/code&gt;.&lt;/strong&gt; Use expand/contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing a numeric column's scale in place.&lt;/strong&gt; Snowflake can't follow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting compatibility to &lt;code&gt;NONE&lt;/code&gt;&lt;/strong&gt; just to get a failed connector running again. You've switched off the only alarm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting function defaults.&lt;/strong&gt; Debezium exports a default like &lt;code&gt;CURRENT_TIMESTAMP(6)&lt;/code&gt; as a zero value (&lt;code&gt;1970-01-01&lt;/code&gt;) in the schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing a schema during an incremental snapshot.&lt;/strong&gt; The Postgres connector doesn't support it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Postgres doesn't send DDL, so the next event carries the new schema.&lt;/li&gt;
&lt;li&gt;The registry checks compatibility, &lt;code&gt;BACKWARD&lt;/code&gt; by default. Optional fields are safe to add or remove; required fields are not.&lt;/li&gt;
&lt;li&gt;Snowflake adds columns, but never renames or changes types.&lt;/li&gt;
&lt;li&gt;In the lakehouse, your sink still sees a rename as a drop and an add.&lt;/li&gt;
&lt;li&gt;So expand, then contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This week, find the last schema change on a captured table and check what it did in the warehouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CDC in Production series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: Change Data Capture (CDC) Explained (&lt;a href="https://dev.to/pipelinewhiteboard/change-data-capture-cdc-explained-from-the-transaction-log-to-a-snowflake-merge-3582"&gt;article&lt;/a&gt; · &lt;a href="https://youtu.be/1NCUrWKV1Mo" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 2: Debezium Postgres to Kafka: Every Setting Explained (&lt;a href="https://youtu.be/XAUP1BoYuY8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Part 3: The Silent CDC Bug: Data Loss With No Errors (&lt;a href="https://youtu.be/YDuH1HnyCU0" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Previous, Part 4: CDC Deletes, Tombstones and the Idempotent MERGE (&lt;a href="https://youtu.be/5FUEvlc_npc" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Part 5: CDC Schema Changes: What Breaks and How to Ship Them (this post · &lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;video&lt;/a&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Next, Part 6: Running CDC in Production: The WAL Slot Trap (&lt;a href="https://youtu.be/omX2bTWQDFk" rel="noopener noreferrer"&gt;video&lt;/a&gt;, out November 2)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If this was useful, the video version has the animated diagrams for every step: &lt;a href="https://youtu.be/uhYmKOktRZ8" rel="noopener noreferrer"&gt;watch it on YouTube&lt;/a&gt;. &lt;strong&gt;Subscribe to &lt;a href="https://www.youtube.com/@PipelineWhiteboard" rel="noopener noreferrer"&gt;Pipeline Whiteboard&lt;/a&gt;&lt;/strong&gt; for more diagram-first data engineering explainers. Next up, in the final part: the WAL slot trap, lag, heartbeats, alerting and runbooks.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>kafka</category>
      <category>snowflake</category>
      <category>database</category>
    </item>
    <item>
      <title>Sentry never ran on my Next.js server, and the dashboard looked fine</title>
      <dc:creator>Asermu</dc:creator>
      <pubDate>Sat, 31 Oct 2026 18:57:14 +0000</pubDate>
      <link>https://dev.to/asermu/sentry-never-ran-on-my-nextjs-server-and-the-dashboard-looked-fine-1gpa</link>
      <guid>https://dev.to/asermu/sentry-never-ran-on-my-nextjs-server-and-the-dashboard-looked-fine-1gpa</guid>
      <description>&lt;p&gt;I run &lt;a href="https://asermu.com" rel="noopener noreferrer"&gt;Asermu&lt;/a&gt;, a novel-writing app built with Next.js and deployed as a Docker image on Railway. I added Sentry a while ago. The dashboard stayed quiet, and I took that as good news.&lt;/p&gt;

&lt;p&gt;It wasn't. Today I found that &lt;strong&gt;server-side Sentry had never started in production&lt;/strong&gt;. Not once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clue
&lt;/h2&gt;

&lt;p&gt;I keep a flag-gated test route that reports a test error to Sentry on purpose. On the server runtime it returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"runtime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodejs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"eventId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"309ea78..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"flushOk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &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;&lt;code&gt;captureException&lt;/code&gt; happily handed back an event id. But &lt;code&gt;Sentry.flush()&lt;/code&gt; returned &lt;code&gt;false&lt;/code&gt;, and nothing arrived in Sentry. The same test on the edge runtime returned &lt;code&gt;flushOk: true&lt;/code&gt;, and its event showed up in Sentry.&lt;/p&gt;

&lt;p&gt;In 90 days, Sentry had received exactly one event: my own edge test. Zero real server errors is not the same as zero errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling out the network
&lt;/h2&gt;

&lt;p&gt;My first guess was Railway's outbound network. So I built the app locally with the production DSN:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How I ran the same build&lt;/th&gt;
&lt;th&gt;&lt;code&gt;flushOk&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;next start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.next/standalone/server.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same build, same env, same machine. Only the server entry point changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;

&lt;p&gt;The Docker image runs the standalone output (&lt;code&gt;output: "standalone"&lt;/code&gt;), which Next.js traces and copies into &lt;code&gt;.next/standalone&lt;/code&gt;. Comparing the two folders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.next/server/instrumentation.js&lt;/code&gt; — &lt;strong&gt;present&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.next/standalone/.next/server/instrumentation.js&lt;/code&gt; — &lt;strong&gt;missing&lt;/strong&gt;, along with the chunks it loads, including the one built from &lt;code&gt;sentry.server.config.ts&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;instrumentation.ts&lt;/code&gt; is where &lt;code&gt;register()&lt;/code&gt; imports the Sentry server config. With that file missing from the standalone build, &lt;code&gt;register()&lt;/code&gt; never runs, &lt;code&gt;Sentry.init()&lt;/code&gt; is never called on Node, and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;captureException&lt;/code&gt; still returns an event id, but nothing is sent&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flush()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onRequestError&lt;/code&gt; reports nothing, so unhandled route errors vanish too&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Edge was fine because its instrumentation is bundled with the edge/middleware output, which standalone does include.&lt;/p&gt;

&lt;p&gt;This was Next.js 16.4.2 with Turbopack and &lt;code&gt;@sentry/nextjs&lt;/code&gt; 10.76.2. I haven't checked other versions, or whether it's already a known issue upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Copy the missing server files into the standalone folder after the build, without overwriting anything Next.js chose to ship, and fail the build if the file is still missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build
&lt;span class="c"&gt;# Standalone omits server/instrumentation.js and the chunks it loads.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; .next/server &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.map'&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.nft.json'&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--parents&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; ../standalone/.next/server/ &lt;span class="se"&gt;\;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ../standalone/.next/server/instrumentation.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a clean build that adds 7 files, about 1.5 MB. The &lt;code&gt;test -f&lt;/code&gt; guard matters: if a future Next.js version moves things around, the deploy fails loudly instead of silently shipping without error reporting again.&lt;/p&gt;

&lt;p&gt;After deploying, the server test returned &lt;code&gt;flushOk: true&lt;/code&gt;, and both a handled error and an unhandled route error showed up in Sentry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"No errors" is a claim you have to prove.&lt;/strong&gt; Fire a test error from every runtime (server, edge, browser) after you set up error tracking, and again after big upgrades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;Sentry.flush()&lt;/code&gt;'s return value&lt;/strong&gt; in a test route. An event id alone proves nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you deploy &lt;code&gt;output: "standalone"&lt;/code&gt;, compare it with &lt;code&gt;next start&lt;/code&gt;.&lt;/strong&gt; Same build, different entry point, different behaviour.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I build Asermu, an offline-first novel-writing app with no AI writing features. I write here about the engineering behind it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Five failure tests for an AI-assisted website audit</title>
      <dc:creator>Procora</dc:creator>
      <pubDate>Sat, 31 Oct 2026 18:56:08 +0000</pubDate>
      <link>https://dev.to/procoraai/five-failure-tests-for-an-ai-assisted-website-audit-427o</link>
      <guid>https://dev.to/procoraai/five-failure-tests-for-an-ai-assisted-website-audit-427o</guid>
      <description>&lt;p&gt;Disclosure: This article was generated with AI assistance for Procora. The tests below are design examples, not a claim that a particular production system has passed them.&lt;/p&gt;

&lt;p&gt;An AI-assisted website audit has two jobs: collect evidence and explain useful next steps. Testing only the happy path makes it easy for missing data to become a confident claim. These five tests focus on that boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A failed fetch must not become a missing-title finding
&lt;/h2&gt;

&lt;p&gt;Arrange an HTTP timeout for one URL. The collector should record a collection failure, with the URL and attempt time. The report should say that the page could not be checked. It must not say the title or heading is absent, because no usable HTML was observed.&lt;/p&gt;

&lt;p&gt;A separate fixture with a successful HTML response and no title should produce a missing-title finding. These outcomes need different identifiers and display states.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An unconnected private source must not show zero traffic
&lt;/h2&gt;

&lt;p&gt;Run a public crawl with no authorised Search Console property. The search-data component should show “Not connected,” not zero clicks or impressions. Then test a connected property whose API request fails: that state should be “Unavailable,” not a successful measurement.&lt;/p&gt;

&lt;p&gt;For a successful request, retain the source, property, date range and retrieval time. If a successful response contains no rows, describe the requested period and the provider's response; do not expand it into “this business has no demand.”&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Unknown evidence references must fail validation
&lt;/h2&gt;

&lt;p&gt;Give the analysis step an evidence set containing E1 and E2. Return a recommendation citing E99. The renderer should reject that reference or mark the recommendation as unsupported. A fluent explanation must not create its own evidence.&lt;/p&gt;

&lt;p&gt;Illustrative validation logic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build the set of known IDs from collected evidence.&lt;/li&gt;
&lt;li&gt;Require each recommendation to cite at least one evidence ID.&lt;/li&gt;
&lt;li&gt;Reject any cited ID outside that set.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This check validates the reference, not the truth of the explanation. Also compare quoted observations with the original record and flag assumptions for review.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Public sharing must not expose private measurements
&lt;/h2&gt;

&lt;p&gt;Create a public website report and a separate, authorised private workspace. Request the public report while private metrics exist. The public response must exclude those metrics, account identifiers and tokens. Test access control on the server; hiding a card in the browser is insufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Screen and PDF must preserve the same limits
&lt;/h2&gt;

&lt;p&gt;Use an audit fixture covering ten sampled pages of a larger website. Confirm that both views identify the sample, collection time and unmeasured areas. If a provider is unavailable, both outputs should retain that status. Exporting should not convert partial coverage into a whole-site claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to assert
&lt;/h2&gt;

&lt;p&gt;For each test, assert both the stored result and the user-facing wording. Keep “observed,” “recommended” and “not measured” separate. Add idempotent retry tests for transient failures so a retry does not duplicate findings or overwrite a successful observation with an unexplained empty state.&lt;/p&gt;

&lt;p&gt;A report is useful when a reader can locate the affected page, understand the evidence, assign a fix and verify the outcome. A decorative score cannot substitute for those steps.&lt;/p&gt;

&lt;p&gt;Procora offers an AI-assisted website audit for small businesses. Explore the &lt;a href="https://procora.ai/en/ai-audit?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=audit_failure_tests" rel="noopener noreferrer"&gt;public website audit&lt;/a&gt;. This article presents a general testing approach; it is not an independent audit of Procora.&lt;/p&gt;

</description>
      <category>testing</category>
    </item>
    <item>
      <title>Echoes After Midnight – A Local AI-Powered Horror Story Generator</title>
      <dc:creator>Shimra Nasardeen</dc:creator>
      <pubDate>Sat, 31 Oct 2026 18:55:13 +0000</pubDate>
      <link>https://dev.to/shimra/echoes-after-midnight-a-local-ai-powered-horror-story-generator-55nb</link>
      <guid>https://dev.to/shimra/echoes-after-midnight-a-local-ai-powered-horror-story-generator-55nb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-Open-Source-AI-Challenge-Week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I built Echoes After Midnight, a local AI-powered horror story generator that transforms users' ideas into spooky stories.&lt;br&gt;
Users can enter a story idea, select a setting, and choose a horror level, such as Mysterious, Medium, Terrifying, or Nightmare. The application then uses an AI language model to generate a horror story based on their selections.&lt;br&gt;
I interpreted the Ghost in the Machine theme by creating an AI-powered storytelling experience where a local language model brings mysterious and frightening stories to life.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://drive.google.com/file/d/1E3RNOYrm_-lQ4I5nNaDkIdpoS1bxWPHX/view?usp=sharing" rel="noopener noreferrer"&gt;https://drive.google.com/file/d/1E3RNOYrm_-lQ4I5nNaDkIdpoS1bxWPHX/view?usp=sharing&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ShimraNazar/EchoesAfterMidnight.git" rel="noopener noreferrer"&gt;https://github.com/ShimraNazar/EchoesAfterMidnight.git&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;I developed the application using Python and Flask for the backend, with HTML, CSS, and JavaScript for the frontend.&lt;br&gt;
For AI-powered story generation, I used TinyLlama 1.1B, a lightweight language model running locally through Ollama.&lt;br&gt;
The application sends the user's story idea, selected setting, and horror level to the Ollama API. The model processes the prompt and generates a horror story, which is then displayed on the website.&lt;br&gt;
Technologies used:&lt;br&gt;
Python&lt;br&gt;
Flask&lt;br&gt;
HTML&lt;br&gt;
CSS&lt;br&gt;
JavaScript&lt;br&gt;
Ollama&lt;br&gt;
TinyLlama 1.1B&lt;br&gt;
Requests&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Open innovation makes AI development more accessible to students and developers. By using open-source tools and models, I was able to experiment with AI integration and build an application without depending on paid AI APIs.&lt;br&gt;
Using Ollama and TinyLlama also allowed me to explore local AI inference and understand how language models can be integrated into practical applications.&lt;br&gt;
This project gave me an opportunity to learn how different open-source technologies can work together to create something useful and creative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;Every horror story begins with an idea, but sometimes the most frightening part is what we cannot see.&lt;br&gt;
With Echoes After Midnight, I wanted to create an experience where users can turn simple ideas into mysterious stories and explore the creative possibilities of AI.&lt;br&gt;
Let the past whisper, and discover what waits after midnight.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>How to read passport data with NFC in a Capacitor app</title>
      <dc:creator>Dzmitry</dc:creator>
      <pubDate>Sat, 31 Oct 2026 18:53:35 +0000</pubDate>
      <link>https://dev.to/muzychenka/i-built-a-capacitor-plugin-for-scanning-passports-and-id-cards-with-nfc-27ko</link>
      <guid>https://dev.to/muzychenka/i-built-a-capacitor-plugin-for-scanning-passports-and-id-cards-with-nfc-27ko</guid>
      <description>&lt;p&gt;Suppose you're building a mobile app with a form for passport details. You could ask users to type everything in. Another option is to scan the document and read data from its chip.&lt;/p&gt;

&lt;p&gt;In this guide, we'll build that flow in TypeScript:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scan the MRZ → read the NFC chip → return document data to your app.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We'll use &lt;a href="https://github.com/muzychenka/capacitor-id-reader" rel="noopener noreferrer"&gt;capacitor-id-reader&lt;/a&gt;, a plugin I built for this workflow on iOS and Android. You can connect the example to your existing Capacitor UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install and configure the plugin
&lt;/h2&gt;

&lt;p&gt;You'll need Capacitor 8, Node.js 22+, and a physical NFC phone running iOS 15+ or Android API 26+. Use a compatible electronic passport or ID card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;capacitor-id-reader
npx cap &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On iOS, configure your app target:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable &lt;strong&gt;Near Field Communication Tag Reading&lt;/strong&gt;, including it in your signing profile.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;TAG&lt;/code&gt; to the &lt;code&gt;com.apple.developer.nfc.readersession.formats&lt;/code&gt; entitlement.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;NFCReaderUsageDescription&lt;/code&gt; and &lt;code&gt;NSCameraUsageDescription&lt;/code&gt; to &lt;code&gt;Info.plist&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;A0000002471001&lt;/code&gt; to the &lt;code&gt;com.apple.developer.nfc.readersession.iso7816.select-identifiers&lt;/code&gt; array in &lt;code&gt;Info.plist&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Android, the plugin declares camera and NFC permissions. Enable NFC on the phone; camera permission is requested when scanning.&lt;/p&gt;

&lt;p&gt;Check the &lt;a href="https://github.com/muzychenka/capacitor-id-reader#ios-setup" rel="noopener noreferrer"&gt;native setup instructions&lt;/a&gt; when configuring your project.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Scan the MRZ and read the chip
&lt;/h2&gt;

&lt;p&gt;The MRZ is the machine-readable text printed on the document. It provides the document number, date of birth and expiry date used for chip access.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NfcIdReader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parseMrz&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;capacitor-id-reader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readPassport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;includePhoto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;NfcIdReader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;available&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFC reading is unavailable on this device.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enable NFC before reading the document.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;NfcIdReader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scanMrz&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Position the document’s MRZ inside the camera view&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cancelLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Go back&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accessData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseMrz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;accessData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The MRZ could not be parsed. Please scan it again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Show an instruction to hold the document against the phone.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;includePhoto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NfcIdReader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;accessData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;dataGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DG1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DG2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NfcIdReader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DG1 contains document details; DG2 contains the face image. The default call reads DG1. Requesting the photo is an explicit choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Connect the flow to a button
&lt;/h2&gt;

&lt;p&gt;Treat reading as a user action with loading, success and failure states. Add this controller alongside &lt;code&gt;readPassport&lt;/code&gt;, and connect its callbacks to your component state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PassportData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Awaited&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;readPassport&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleReadClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;setBusy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;busy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;showDocument&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PassportData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBusy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readPassport&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reading failed. Please retry.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBusy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disable the button while busy. On success, use &lt;code&gt;showDocument&lt;/code&gt; to populate a review screen before the user submits anything.&lt;/p&gt;

&lt;p&gt;The catch block is a starting point. For your app's UX, map the exported &lt;code&gt;ENfcIdReaderError&lt;/code&gt; codes to appropriate messages, including cancellation and denied camera permission.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Optionally display the face image
&lt;/h2&gt;

&lt;p&gt;Call &lt;code&gt;readPassport(true)&lt;/code&gt; to request DG2 in the same NFC session. Then prepare a JPEG URL for your image component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readPassport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;faceImage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jpeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;previewJpegBase64&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;mimeType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;photoUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jpeg&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`data:image/jpeg;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind &lt;code&gt;photoUrl&lt;/code&gt; to an image when it exists. A JPEG 2000 original may need a decoder. See the &lt;a href="https://github.com/muzychenka/capacitor-id-reader#optional-dg2-face-image" rel="noopener noreferrer"&gt;DG2 documentation&lt;/a&gt; for platform details and failure behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before shipping
&lt;/h2&gt;

&lt;p&gt;A successful chip read does not establish document authenticity, ownership or liveness. The plugin does not implement passive authentication or certificate trust validation. Reading data alone must not be treated as verified identity.&lt;/p&gt;

&lt;p&gt;Compatibility and validation work remain. Test your exact package version with representative documents on physical devices, including interrupted reads and retries. Review the &lt;a href="https://github.com/muzychenka/capacitor-id-reader/blob/main/AUDIT.md" rel="noopener noreferrer"&gt;audit and known limitations&lt;/a&gt;, and avoid logging document data or face images.&lt;/p&gt;

&lt;p&gt;You now have a camera-to-NFC flow to connect to your document form. Map the returned data to your fields and let the user review it before submission.&lt;/p&gt;

&lt;p&gt;The full API, native setup and device-test example are in the &lt;a href="https://github.com/muzychenka/capacitor-id-reader" rel="noopener noreferrer"&gt;capacitor-id-reader repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>capacitor</category>
      <category>ionic</category>
      <category>opensource</category>
      <category>nfc</category>
    </item>
  </channel>
</rss>
