<?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: Doby Baxter</title>
    <description>The latest articles on DEV Community by Doby Baxter (@dobybaxter127).</description>
    <link>https://dev.to/dobybaxter127</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4027698%2Fe5a99272-983d-45cf-a7e5-630fa7729877.png</url>
      <title>DEV Community: Doby Baxter</title>
      <link>https://dev.to/dobybaxter127</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dobybaxter127"/>
    <language>en</language>
    <item>
      <title>You Don't Need Perfect Code - You Need a Safety Net That Talks Back</title>
      <dc:creator>Doby Baxter</dc:creator>
      <pubDate>Tue, 14 Jul 2026 17:11:14 +0000</pubDate>
      <link>https://dev.to/dobybaxter127/you-dont-need-perfect-code-you-need-a-safety-net-that-talks-back-3nl7</link>
      <guid>https://dev.to/dobybaxter127/you-dont-need-perfect-code-you-need-a-safety-net-that-talks-back-3nl7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — My last post was about catching &lt;em&gt;drift&lt;/em&gt;: the moment my config tool's idea of "valid" silently diverges from the real Pyxel framework. This post is about the other half of the same system — what happens the instant a human hits one of those checks. A safety net has two jobs: it has to &lt;em&gt;catch&lt;/em&gt; you, and it has to &lt;em&gt;talk to you&lt;/em&gt; when it does. Perfect code chases the first and forgets the second. I dug into ~60 years of error-message research to work out what a net that talks back should actually say, and mapped each finding onto a concrete rule I now hold my own diagnostics to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The myth I had to give up first
&lt;/h2&gt;

&lt;p&gt;For a long time I treated "write better code" as the goal, as if enough care up front would let me arrive at software that simply didn't go wrong. That belief survives right up until you maintain a tool whose correctness depends on something you don't own. My browser-based configuration tool exists to help scientists write YAML that ESA's Pyxel detector-simulation framework will accept. Pyxel is the source of truth. My copy of that truth can drift, users will make mistakes I can't anticipate, and no amount of cleverness on my side changes either fact.&lt;/p&gt;

&lt;p&gt;So I stopped aiming for code that never fails and started building a net that assumes it will. In the &lt;a href="https://dev.to/dobybaxter127/testing-against-state-drift-guarding-a-config-tool-whose-source-of-truth-lives-somewhere-else-kgf"&gt;drift post&lt;/a&gt; I wrote about the catching layers — freshness checks, bounds tests, API introspection, per-file coverage gates. But a net that catches you silently and then hands you a stack trace is only half a net. The half nobody budgets for is the part that speaks.&lt;/p&gt;

&lt;p&gt;Here's the thing I didn't expect: the "speaking" half turns out to be the more researched of the two, and the research is unambiguous.&lt;/p&gt;

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

&lt;p&gt;I went looking for evidence rather than opinion, because "be nicer to your users" is easy to say and easy to skip. The literature is older and firmer than I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;People read error messages — and it's expensive.&lt;/strong&gt; The comfortable myth is that developers skip the message and jump straight to the code. Barik et al. put 56 developers in front of an eye-tracker and found the opposite: people genuinely read error messages, they allocate around a quarter of their visual attention to them, and the &lt;em&gt;difficulty&lt;/em&gt; of reading a message is comparable to the difficulty of reading source code itself. Your error text isn't a footnote to the real work — it &lt;em&gt;is&lt;/em&gt; real work you're imposing on someone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better messages measurably help.&lt;/strong&gt; When Becker and colleagues rewrote cryptic compiler errors into enhanced ones, the group receiving them made fewer errors overall, fewer errors per person, and — the metric I care about most — fewer &lt;em&gt;repeated&lt;/em&gt; errors, the signature of someone stuck in a loop. The effect has been debated in the literature, which is itself a useful lesson: rewording alone isn't magic. What helps is a message that names the likely cause and the next action, not just prettier phrasing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's fundamentally an interface problem.&lt;/strong&gt; Traver's HCI analysis reframes the compiler as an interface between machine and human, and the error message as the primary channel of that interface. The message has to bridge the gap between the system's internal state and the user's mental model of what they were trying to do. When it fails, it usually fails because it describes the machine's world ("expected identifier") instead of the user's world ("this needs a variable name").&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readability is relative.&lt;/strong&gt; Later work found that whether a message reads as "clear" depends heavily on the reader's experience and language familiarity — experts and novices don't even agree on what counts as unreadable. There is no universally clear message, only a message clear &lt;em&gt;to a specific audience&lt;/em&gt;. Mine is scientists, not compiler engineers, and that changes every wording decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And there's a 40-year-old checklist waiting for us.&lt;/strong&gt; Nielsen's ninth usability heuristic — help users recognize, diagnose, and recover from errors — has been the same since the 1990s: plain language, precise identification of the problem, and a constructive, actionable path out, without blaming the person. His fifth heuristic pairs with it: the best error is the one you prevented from being possible at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping the studies to rules I actually hold myself to
&lt;/h2&gt;

&lt;p&gt;Findings are only worth the searches if they change what I do on Monday. Here's the translation, each rule tied to the study behind it and to what it looks like inside my tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 1 — Treat the message as a first-class output, budgeted like code
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;From:&lt;/em&gt; Barik (reading a message costs as much as reading code).&lt;/p&gt;

&lt;p&gt;If the message is as expensive to read as the code, it deserves as much care as the code. In practice that means the module that turns raw validator noise into human diagnostics is not a "nice to have" I bolt on at the end — it sits behind the same per-file coverage gate as my parsing and bounds logic. The safety net's &lt;em&gt;voice&lt;/em&gt; is part of the net, so it gets tested like the rest of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2 — Frontload the fix, because you're spending someone's attention
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;From:&lt;/em&gt; Barik (reading is expensive) plus Nielsen's guidance to frontload the important content so users can scan.&lt;/p&gt;

&lt;p&gt;Every message is a withdrawal from someone's attention budget. So the fix goes first, not buried under a paragraph of context. Compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: instance.detector.optics[2] failed schema validation:
data.cutoff_wavelength must be &amp;lt;= 15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cutoff_wavelength is 18.0 but dark_current_rule07 only accepts 1.7–15.0 µm.
Lower it to within that range, or check you meant a different model.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same underlying failure. The second one hands back most of those minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 3 — Enhance with cause and action, not just nicer words
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;From:&lt;/em&gt; Becker (enhanced messages reduce repeated errors) plus the debate around it (rewording alone is weak).&lt;/p&gt;

&lt;p&gt;A repeated error means someone is stuck in a loop the message failed to break. So a good diagnostic in my tool answers three questions in order: what's wrong, why it's wrong &lt;em&gt;here&lt;/em&gt;, and what to do next. The raw AJV output answers only the first, in the machine's dialect. My job is to add the other two — and to resist the temptation to think prettier phrasing is the whole job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 4 — Speak the user's domain, not the parser's
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;From:&lt;/em&gt; Traver (the message must bridge system state and the user's mental model).&lt;/p&gt;

&lt;p&gt;My users think in detectors, wavelengths, and pipeline stages, not in "tokens," "instances," or "discriminated unions." So when a model is placed under the wrong pipeline stage, the message says exactly that in Pyxel's own vocabulary rather than surfacing the JSON-schema machinery underneath. The parser's world leaks out constantly; part of the tool's job is to translate it back into the world the scientist actually inhabits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 5 — Write for &lt;em&gt;your&lt;/em&gt; reader's level, and prove it
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;From:&lt;/em&gt; the readability research (clarity is relative to experience and familiarity).&lt;/p&gt;

&lt;p&gt;There's no such thing as an objectively clear message, so "clear to a scientist who is not a career programmer" becomes the target. Practically, that means plain sentences, a low reading grade, and jargon only where it's &lt;em&gt;their&lt;/em&gt; jargon (cutoff wavelength, yes; discriminator, no). This is checkable — you can run message text through a readability tool the same way you'd lint code — which turns "be clearer" from a vibe into a measurement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 6 — Recognize, diagnose, recover — and prevent
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;From:&lt;/em&gt; Nielsen's heuristics 9 and 5.&lt;/p&gt;

&lt;p&gt;This is the spine that holds the other rules together, and it maps cleanly onto the layers I already have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recognize&lt;/strong&gt; — surface the problem at the seam where it lives, visibly, so it can't be missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diagnose&lt;/strong&gt; — say precisely what and where, in the user's terms (Rules 3–5).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recover&lt;/strong&gt; — give an actionable next step, ideally with the valid range or the nearest correct option (Rule 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevent&lt;/strong&gt; — the best error message is the one that never has to appear. My bounds warnings and schema freshness checks are heuristic 5 in disguise: they stop whole classes of invalid config before they're ever submitted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rule 7 — Never blame, and never claim certainty you don't have
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;From:&lt;/em&gt; Nielsen (constructive, non-accusatory) plus my own drift work (a check that can silently discover nothing is worse than no check).&lt;/p&gt;

&lt;p&gt;Tone is a design decision. A message that reads as "you did this wrong" produces the person who stops trusting the tool; a message that reads as "here's what happened and how to move" produces the person who fixes it and moves on. And there's a deeper honesty rule that connects straight back to the drift post: when my tool can't verify freshness because Pyxel is unreachable, it says so plainly rather than pretending everything is current. The invariant isn't "the tool is always right." It's "the tool never lies about what it knows." An error message that overstates its own certainty is just a politely-worded version of the silent check that discovers nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The synthesis: a net catches &lt;em&gt;and&lt;/em&gt; speaks
&lt;/h2&gt;

&lt;p&gt;Put the two posts side by side and the shape is clear. Perfect code is a single strand — get everything right and you never fall. It's also a fantasy, because in any system whose truth lives somewhere else, you don't control whether you fall. A safety net is the honest alternative, and it's made of two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;catching&lt;/strong&gt; layer — the drift tests, the validation, the coverage gates. This decides &lt;em&gt;whether&lt;/em&gt; you fall.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;speaking&lt;/strong&gt; layer — the error messages. This decides whether the fall teaches you something or just hurts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used to pour all my effort into the first strand and call it engineering. The research quietly makes the case that the second strand is where a huge share of your users' time, frustration, and trust actually lives — and that it's been well-understood for decades while most of us kept treating it as an afterthought.&lt;/p&gt;

&lt;p&gt;You don't need code that never fails. You need a net that catches you, and then has the decency to tell you — in your own language, without blame, with the fix in hand — exactly what just happened.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Perfect code is the wrong target; a safety net is the right one.&lt;/strong&gt; When you don't own the source of truth, you can't prevent every fall — so design for the fall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The message is not a footnote.&lt;/strong&gt; Reading it costs as much as reading code, so budget and test it like code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontload the fix.&lt;/strong&gt; You're spending someone's attention; give the answer before the context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enhance with cause and next step&lt;/strong&gt;, not just softer wording — a repeated error is a message that failed to break the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speak the user's domain and the user's reading level&lt;/strong&gt;, and measure both instead of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recognize, diagnose, recover, prevent&lt;/strong&gt; — Nielsen's 40-year-old checklist still maps onto every layer of a modern config tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never blame, and never claim certainty you can't back up.&lt;/strong&gt; An over-confident message is the friendly face of a check that silently discovers nothing.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Barik, T. et al. &lt;em&gt;Do Developers Read Compiler Error Messages?&lt;/em&gt; ICSE 2017.&lt;/li&gt;
&lt;li&gt;Becker, B. A. &lt;em&gt;An Effective Approach to Enhancing Compiler Error Messages.&lt;/em&gt; SIGCSE 2016; and &lt;em&gt;An Exploration of the Effects of Enhanced Compiler Error Messages for Computer Programming Novices&lt;/em&gt; (2015).&lt;/li&gt;
&lt;li&gt;Becker, B. A. et al. &lt;em&gt;Compiler Error Messages Considered Unhelpful: The Landscape of Text-Based Programming Error Message Research.&lt;/em&gt; ITiCSE-WGR 2019.&lt;/li&gt;
&lt;li&gt;Denny, P., Becker, B. A. et al. &lt;em&gt;On Designing Programming Error Messages for Novices: Readability and its Constituent Factors.&lt;/em&gt; CHI 2021.&lt;/li&gt;
&lt;li&gt;Traver, V. J. &lt;em&gt;On Compiler Error Messages: What They Say and What They Mean.&lt;/em&gt; Advances in Human-Computer Interaction, 2010.&lt;/li&gt;
&lt;li&gt;Nielsen, J. &lt;em&gt;10 Usability Heuristics for User Interface Design&lt;/em&gt; (Heuristics 5 and 9); Nielsen Norman Group, &lt;em&gt;Error-Message Guidelines.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>devops</category>
      <category>ux</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building a FastAPI Backend That Serves Live Satellite Imagery and Orbital Position</title>
      <dc:creator>Doby Baxter</dc:creator>
      <pubDate>Tue, 14 Jul 2026 03:08:58 +0000</pubDate>
      <link>https://dev.to/dobybaxter127/building-a-fastapi-backend-that-serves-live-satellite-imagery-and-orbital-position-3dpn</link>
      <guid>https://dev.to/dobybaxter127/building-a-fastapi-backend-that-serves-live-satellite-imagery-and-orbital-position-3dpn</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I built a full-stack app that shows one place — the Sheffield Cholera Monument — from two scales at once: a satellite's-eye vegetation view from space, and a centimeter-scale 3D model from the ground. This post is about the backend: a FastAPI service that authenticates against the Copernicus Sentinel Hub, pulls the &lt;em&gt;newest&lt;/em&gt; real Sentinel-2 scene over a bounding box, computes NDVI, and then pins a satellite marker to the exact point in orbit where Sentinel-2A passed closest to the monument for that scene. The satellite-data pipeline is the heart of it, so that's where I'll spend most of the words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll get out of it&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How to obtain live satellite imagery from Copernicus: OAuth token, then the Catalog, Process, and Statistics APIs&lt;/li&gt;
&lt;li&gt;Why "the newest scene" is a real query, not a timestamp — and how to pin every view to that one scene&lt;/li&gt;
&lt;li&gt;Turning a two-line element (TLE) into a satellite position with Skyfield + SGP4, vectorized with NumPy&lt;/li&gt;
&lt;li&gt;Designing endpoints that &lt;em&gt;never&lt;/em&gt; break the frontend, even when an upstream API is down&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The idea: one place, two scales&lt;/li&gt;
&lt;li&gt;Getting satellite data, step one: authentication&lt;/li&gt;
&lt;li&gt;Step two: finding the newest real scene&lt;/li&gt;
&lt;li&gt;Step three: NDVI imagery and statistics&lt;/li&gt;
&lt;li&gt;Step four: from a TLE to a position in orbit&lt;/li&gt;
&lt;li&gt;Pinning the satellite to the scene&lt;/li&gt;
&lt;li&gt;Designing endpoints that can't break the frontend&lt;/li&gt;
&lt;li&gt;Keeping it light: caching&lt;/li&gt;
&lt;li&gt;Takeaways&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  The idea: one place, two scales
&lt;/h3&gt;

&lt;p&gt;The project's thesis is simple: &lt;em&gt;observation scale changes what you can understand about a place.&lt;/em&gt; From orbit, Sentinel-2 sees vegetation health across a whole neighborhood at ~10 m per pixel but knows nothing of texture. From the ground, a photogrammetric mesh captures sub-centimeter detail of a single monument but sees nothing of its surroundings. Put both on one interactive globe and the contrast becomes the point.&lt;/p&gt;

&lt;p&gt;The frontend is a CesiumJS 3D globe; the ground model is a RealityScan &lt;code&gt;.glb&lt;/code&gt;. Neither is what makes this a backend story. What makes it a backend story is everything that has to happen server-side to get &lt;em&gt;live, honest, self-consistent&lt;/em&gt; satellite data onto that globe — where "self-consistent" means the imagery, the reported date, and the satellite's position all describe &lt;strong&gt;the same acquisition&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The stack for that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API server&lt;/td&gt;
&lt;td&gt;FastAPI + Uvicorn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Imagery &amp;amp; metadata&lt;/td&gt;
&lt;td&gt;Copernicus Sentinel Hub (Catalog / Process / Statistics APIs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orbital mechanics&lt;/td&gt;
&lt;td&gt;Skyfield (SGP4 propagation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live orbital elements&lt;/td&gt;
&lt;td&gt;Celestrak TLE feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Math&lt;/td&gt;
&lt;td&gt;NumPy (vectorized)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four endpoints: &lt;code&gt;latest-scene&lt;/code&gt;, &lt;code&gt;ndvi&lt;/code&gt;, &lt;code&gt;ndvi-stats&lt;/code&gt;, and &lt;code&gt;orbit-at-scene-time&lt;/code&gt;. Let's build the data pipeline in the order the data actually flows.&lt;/p&gt;




&lt;h3&gt;
  
  
  Getting satellite data, step one: authentication
&lt;/h3&gt;

&lt;p&gt;Copernicus Sentinel Hub uses OAuth2 client-credentials. You register an app in the Copernicus Data Space, get a client ID and secret, and exchange them for a short-lived bearer token that every subsequent request carries. The secret never touches the frontend — it lives in backend environment variables (set in the Render dashboard in production), which is the entire reason a backend exists here rather than calling the API from the browser.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TOKEN_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_token&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;TOKEN_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&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 the whole handshake. Every data call below starts by getting a token and putting it in an &lt;code&gt;Authorization: Bearer …&lt;/code&gt; header. The backend is, among other things, an &lt;strong&gt;auth proxy&lt;/strong&gt;: it holds the credentials, and the browser only ever talks to the backend.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step two: finding the newest real scene
&lt;/h3&gt;

&lt;p&gt;Here's the design decision that shapes everything: the dashboard shows &lt;em&gt;the newest available scene&lt;/em&gt;, and reports that scene's &lt;strong&gt;real acquisition date&lt;/strong&gt; — not the time you happened to load the page. Satellites don't image every spot continuously; Sentinel-2 revisits a given area every few days, and cloud cover knocks out many passes. So "what's the latest usable image of this exact place?" is a genuine query.&lt;/p&gt;

&lt;p&gt;That query goes to the Sentinel Hub &lt;strong&gt;Catalog API&lt;/strong&gt;. I ask for every Sentinel-2 L2A scene intersecting my bounding box in the last 30 days, then sort client-side and pick the newest one under a cloud-cover threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BBOX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.4635&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;53.3745&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.4550&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;53.3798&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# around the monument
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_latest_scene_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&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="n"&gt;max_cloud&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="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;collections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentinel-2-l2a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BBOX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;dT&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;SZ&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;dT&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;SZ&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_token&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CATALOG_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;feats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;features&lt;/span&gt;&lt;span class="sh"&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;# newest first
&lt;/span&gt;    &lt;span class="n"&gt;feats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# newest scene under the cloud threshold, else newest of all
&lt;/span&gt;    &lt;span class="n"&gt;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;feats&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eo:cloud_cover&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_cloud&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;feats&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chosen&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cloud_cover&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eo:cloud_cover&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small robustness choices worth calling out. Sorting happens &lt;strong&gt;client-side&lt;/strong&gt; rather than via a &lt;code&gt;sortby&lt;/code&gt; in the request, because not every catalog deployment supports that parameter reliably — sorting 50 features locally is free and removes a compatibility assumption. And the &lt;code&gt;or feats[0]&lt;/code&gt; fallback means that if &lt;em&gt;every&lt;/em&gt; recent scene is cloudy, the dashboard still shows the newest one rather than nothing; a cloudy real scene beats an empty panel. The whole function returns &lt;code&gt;None&lt;/code&gt; on any failure so callers can degrade gracefully — a pattern that becomes a theme.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;latest-scene&lt;/code&gt; endpoint wraps this and returns a clean metadata object. Crucially, that scene's &lt;code&gt;datetime&lt;/code&gt; becomes the anchor every other view is tied to.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step three: NDVI imagery and statistics
&lt;/h3&gt;

&lt;p&gt;NDVI — the Normalized Difference Vegetation Index — is the classic "how green is it" measure: &lt;code&gt;(NIR − Red) / (NIR + Red)&lt;/code&gt;, using Sentinel-2's B08 (near-infrared) and B04 (red) bands. Healthy vegetation reflects strongly in NIR and absorbs red, so it scores high.&lt;/p&gt;

&lt;p&gt;Sentinel Hub's &lt;strong&gt;Process API&lt;/strong&gt; lets you compute this server-side at the source with an &lt;em&gt;evalscript&lt;/em&gt; — a small JS function that runs per pixel on the imagery — and hand back a finished PNG. No raw bands cross the network; you receive exactly the visualization you asked for. I bucket the NDVI values into a simple color ramp from bare-earth brown to dense-vegetation green:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;evalscript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
//VERSION=3
function setup() {
  return { input: [&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B04&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B08&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;], output: { bands: 3 } };
}
function evaluatePixel(sample) {
  let ndvi = (sample.B08 - sample.B04) / (sample.B08 + sample.B04);
  if (ndvi &amp;lt; 0.0) return [0.3, 0.2, 0.1];
  if (ndvi &amp;lt; 0.2) return [0.8, 0.7, 0.3];
  if (ndvi &amp;lt; 0.4) return [0.4, 0.8, 0.3];
  if (ndvi &amp;lt; 0.6) return [0.2, 0.6, 0.2];
  return [0.0, 0.4, 0.0];
}
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request sends this evalscript alongside the bounding box, a 30-day time range, a 30% cloud filter, and a &lt;code&gt;512×512&lt;/code&gt; output size, then streams the PNG straight back to the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PROCESS_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image/png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;512×512 is a deliberate balance: fine enough to show real spatial structure at Sentinel-2's resolution, small enough to stay snappy for real-time draping over the globe.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ndvi-stats&lt;/code&gt; endpoint does the numerical companion via the &lt;strong&gt;Statistics API&lt;/strong&gt;, aggregating NDVI daily over a 60-day window and pulling out the mean — this time with a &lt;code&gt;dataMask&lt;/code&gt; band so cloud/no-data pixels are excluded from the average rather than dragging it down. It returns a single &lt;code&gt;{"mean": 0.342}&lt;/code&gt; for the telemetry readout.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step four: from a TLE to a position in orbit
&lt;/h3&gt;

&lt;p&gt;This is the part I find most fun, and the part most people haven't built. I want a marker on the globe showing &lt;em&gt;where Sentinel-2A actually was&lt;/em&gt; when it captured the scene — sitting on its real ground track, not a decorative dot.&lt;/p&gt;

&lt;p&gt;The raw material is a &lt;strong&gt;TLE&lt;/strong&gt; (two-line element set): a compact, standardized encoding of a satellite's orbit at a moment in time (its "epoch"). Celestrak publishes fresh TLEs for active satellites. I fetch the active catalog and pull out Sentinel-2A specifically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_sentinel2a_tle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TLE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&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;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&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;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SENTINEL-2A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# …hardcoded fallback TLE if the fetch fails…
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A TLE isn't a position — it's the input to a &lt;em&gt;propagator&lt;/em&gt;. &lt;strong&gt;SGP4&lt;/strong&gt; is the standard model that turns "here's the orbit" plus "here's a time" into an actual Earth-centered coordinate. &lt;strong&gt;Skyfield&lt;/strong&gt; wraps SGP4 in a friendly API and does the coordinate conversions. Feeding it the TLE and an array of times gives sub-satellite points (the latitude/longitude directly beneath the satellite):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;skyfield.api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EarthSatellite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wgs84&lt;/span&gt;

&lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timescale&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_sentinel2a_tle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;sat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EarthSatellite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# a whole array of times at once
&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wgs84&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;seconds_array&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;lats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thing to notice: Skyfield is &lt;strong&gt;vectorized&lt;/strong&gt;. You pass a NumPy array of time offsets and get back arrays of positions in one call, rather than looping second-by-second. That matters a lot for the next step, where I evaluate the orbit at thousands of instants.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pinning the satellite to the scene
&lt;/h3&gt;

&lt;p&gt;Reporting Sentinel-2A's position at some arbitrary instant would put the marker wherever the satellite happens to be — usually nowhere near Sheffield. What I actually want is its &lt;strong&gt;closest approach to the monument&lt;/strong&gt; around the scene's acquisition time. I find it with a coarse-then-fine search, both fully vectorized:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_haversine_km&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lons&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dlat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lats&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;MONUMENT_LAT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dlon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lons&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;MONUMENT_LON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dlat&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="o"&gt;**&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
         &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MONUMENT_LAT&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lats&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dlon&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="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;return&lt;/span&gt; &lt;span class="mi"&gt;6371&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arctan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# Coarse scan: ±1 day around the scene, one sample per 60 s
&lt;/span&gt;&lt;span class="n"&gt;sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wgs84&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_haversine_km&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;coarse_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;))])&lt;/span&gt;

&lt;span class="c1"&gt;# Refine: ±60 s around that minimum, one sample per second
&lt;/span&gt;&lt;span class="n"&gt;rsec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coarse_s&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coarse_s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;61&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rsp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wgs84&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rsec&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;rdist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_haversine_km&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;closest_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rsec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rdist&lt;/span&gt;&lt;span class="p"&gt;))])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The haversine gives great-circle distance from each sub-satellite point to the monument. The coarse pass (2,880 samples across two days) narrows to the right minute; the refinement (121 one-second samples) pinpoints the exact second of closest approach. Because the newest scene &lt;em&gt;was itself captured&lt;/em&gt; as Sentinel-2A flew over Sheffield, that closest approach correctly lands right over the monument — the imagery and the orbit agree because they describe the same overpass.&lt;/p&gt;

&lt;p&gt;Finally I build a ±45-minute ground track (181 points at 30-second spacing) centered on that closest second, so the frontend can draw the arc of the orbit with the marker glued to the middle of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tsec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;closest_s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2700&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2701&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ±45 min, 30 s steps
&lt;/span&gt;&lt;span class="n"&gt;tsp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wgs84&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tsec&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;track_points&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;altitude_m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SAT_ALTITUDE_M&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;la&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degrees&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;One honesty note I put in the README too: this design deliberately only tracks the &lt;em&gt;newest&lt;/em&gt; scene, whose date is always recent. That keeps the current Celestrak TLE's epoch close to the scene time, so SGP4 stays accurate without hunting down epoch-matched historical TLEs. Historical-scene accuracy is intentionally out of scope — a scoped limitation is better than a silent inaccuracy.&lt;/p&gt;




&lt;h3&gt;
  
  
  Designing endpoints that can't break the frontend
&lt;/h3&gt;

&lt;p&gt;The globe refreshes every five minutes and calls all four endpoints. Any of them depends on a third party — Copernicus, Celestrak — that can rate-limit, time out, or briefly vanish. A 500 error here isn't just an empty panel; because of how CORS works, a raised exception can arrive at the browser &lt;em&gt;without&lt;/em&gt; the CORS headers, turning a transient upstream hiccup into a confusing cross-origin error in the console.&lt;/p&gt;

&lt;p&gt;So the orbit endpoint is built to &lt;strong&gt;always return valid JSON&lt;/strong&gt;, falling back to a safe position over the monument rather than raising:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/orbit-at-scene-time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;orbit&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="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_latest_scene_meta&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;ref_dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_iso&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datetime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref_dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_orbit_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;_orbit_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_orbit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref_dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;_orbit_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_orbit_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orbit endpoint failed, returning fallback: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_fallback_orbit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_fallback_orbit()&lt;/code&gt; returns a well-formed response with the marker parked over Sheffield and a short straight track. The globe still renders; the user sees the site; the CORS headers are present because FastAPI returned normally. The same philosophy runs through the stack: &lt;code&gt;latest-scene&lt;/code&gt; falls back to a &lt;code&gt;null&lt;/code&gt; cloud cover, &lt;code&gt;get_latest_scene_meta&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; instead of throwing, and the TLE fetch has a hardcoded backup. &lt;strong&gt;Every external dependency has a graceful degradation path&lt;/strong&gt;, because in a live dashboard "slightly stale but rendered" always beats "broken."&lt;/p&gt;




&lt;h3&gt;
  
  
  Keeping it light: caching
&lt;/h3&gt;

&lt;p&gt;Two small caches keep the Render instance from re-doing expensive work on every poll. Catalog metadata is cached for 30 minutes — new Sentinel-2 scenes don't appear more often than that, so hammering the Catalog API on every request buys nothing. And the orbit is recomputed &lt;strong&gt;only when the newest scene actually changes&lt;/strong&gt;, keyed on the scene datetime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_orbit_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;_orbit_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_orbit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref_dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_orbit_cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since the scene changes every few days but the frontend polls every five minutes, this turns thousands of SGP4 computations per day into a handful. The coarse+fine orbit search is cheap in isolation, but not free at 512×512-plus-thousands-of-samples on every poll from every visitor; keying the cache to the &lt;em&gt;scene&lt;/em&gt; rather than a clock interval means the work happens exactly when the underlying data does, and never otherwise.&lt;/p&gt;




&lt;h3&gt;
  
  
  Takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A backend earns its place as a credential boundary.&lt;/strong&gt; The moment you need an API secret, the browser can't be trusted with it — the backend holds the OAuth handshake and proxies the data. That's the honest reason this isn't a pure frontend app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Latest" is a query, not a timestamp.&lt;/strong&gt; For real observational data, the newest &lt;em&gt;usable&lt;/em&gt; record — under a cloud threshold, within a lookback window — is something you look up and then anchor everything else to, so your views stay self-consistent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push computation to the data.&lt;/strong&gt; Sentinel Hub evalscripts compute NDVI at the source and return a finished PNG; you move a picture, not gigabytes of raw bands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vectorize the physics.&lt;/strong&gt; Skyfield + NumPy evaluate an orbit at thousands of times in a couple of calls. A coarse-then-fine search over those arrays finds an exact closest approach without a Python loop in sight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In a live dashboard, always return valid JSON.&lt;/strong&gt; Every third-party call gets a graceful fallback, partly for UX and partly because a raised exception can strip your CORS headers and turn a hiccup into a cross-origin error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache to the data's own rhythm.&lt;/strong&gt; Key expensive work to the thing that actually changes (the scene) rather than a fixed interval, and recompute exactly when — and only when — reality does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The satellite-data pipeline is the part I'd point anyone to first: authenticate, find the real newest scene, compute vegetation at the source, then turn a live TLE into an exact position in orbit — each step tied to the same acquisition so the whole globe tells one true, consistent story about one small patch of Sheffield.&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>datascience</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>Testing Against State Drift: Guarding a Config Tool Whose Source of Truth Lives Somewhere Else</title>
      <dc:creator>Doby Baxter</dc:creator>
      <pubDate>Tue, 14 Jul 2026 02:54:49 +0000</pubDate>
      <link>https://dev.to/dobybaxter127/testing-against-state-drift-guarding-a-config-tool-whose-source-of-truth-lives-somewhere-else-kgf</link>
      <guid>https://dev.to/dobybaxter127/testing-against-state-drift-guarding-a-config-tool-whose-source-of-truth-lives-somewhere-else-kgf</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I maintain a browser-based configuration tool for &lt;a href="https://esa.gitlab.io/pyxel/" rel="noopener noreferrer"&gt;ESA's Pyxel&lt;/a&gt; detector-simulation framework. Its whole job is to help people write configs that a &lt;em&gt;separate, independently-versioned&lt;/em&gt; project will accept. That makes drift — my tool's idea of "valid" quietly falling out of sync with Pyxel's — the central failure mode. This post is about the testing and automation I built to catch drift at three different layers, and the one principle that ties them together: &lt;strong&gt;test each invariant at the seam where it actually lives.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll get out of it&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A concrete way to think about &lt;em&gt;state drift&lt;/em&gt; when your correctness depends on an external source of truth&lt;/li&gt;
&lt;li&gt;Three drift-defense layers — bundled-schema freshness, real-schema bounds tests, and API-introspection checks — with the actual code&lt;/li&gt;
&lt;li&gt;Why I split the suite into &lt;em&gt;automated deterministic logic&lt;/em&gt; vs &lt;em&gt;manually-verified rendered output&lt;/em&gt;, and how to draw that line&lt;/li&gt;
&lt;li&gt;How per-file coverage gates in CI turn "we should keep this tested" into "the pipeline won't let us not"&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The problem: correctness you don't own&lt;/li&gt;
&lt;li&gt;Three kinds of drift&lt;/li&gt;
&lt;li&gt;Layer 1 — Is the bundled schema still fresh?&lt;/li&gt;
&lt;li&gt;Layer 2 — Do the real schema's bounds still hold?&lt;/li&gt;
&lt;li&gt;Layer 3 — Do the docs still match the actual API?&lt;/li&gt;
&lt;li&gt;Testing at the seam&lt;/li&gt;
&lt;li&gt;Making the gates non-optional&lt;/li&gt;
&lt;li&gt;What's deliberately not automated&lt;/li&gt;
&lt;li&gt;Takeaways&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  The problem: correctness you don't own
&lt;/h3&gt;

&lt;p&gt;Most testing advice quietly assumes you own the definition of "correct." Your code, your tests, your rules. When a test fails, something in &lt;em&gt;your&lt;/em&gt; repo changed.&lt;/p&gt;

&lt;p&gt;Config tooling breaks that assumption. My tool exists to help scientists produce YAML that Pyxel — a large, actively-developed simulation framework on its own release cadence — will accept and run. Pyxel is the source of truth for what a valid config &lt;em&gt;is&lt;/em&gt;. My tool is a helpful intermediary that holds a &lt;em&gt;copy&lt;/em&gt; of that truth: a bundled JSON schema, a set of tutorial examples, a mapping of detector types to parameter bounds.&lt;/p&gt;

&lt;p&gt;The instant Pyxel ships a new model, renames an argument, or tightens a numeric range, my copy is wrong — and nothing in my repo changed to tell me. The tests still pass. The build still goes green. The tool now confidently blesses configs that Pyxel will reject, or flags valid ones as broken. That's &lt;strong&gt;state drift&lt;/strong&gt;: two systems that are supposed to agree, silently diverging, with no failing assertion at the moment of divergence.&lt;/p&gt;

&lt;p&gt;You can't unit-test your way out of that with fixtures alone, because a fixture is &lt;em&gt;also&lt;/em&gt; a frozen copy of the truth. If Pyxel drifts, your fixture drifts with it and your green test is lying to you. Guarding against drift means deliberately reaching for the real, external thing at test time — carefully, at the right layer.&lt;/p&gt;




&lt;h3&gt;
  
  
  Three kinds of drift
&lt;/h3&gt;

&lt;p&gt;Once I framed the tool this way, the drift surface split cleanly into three, each needing a different defense:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;The copy that can drift&lt;/th&gt;
&lt;th&gt;The truth it must match&lt;/th&gt;
&lt;th&gt;How I detect it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Schema freshness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;bundled &lt;code&gt;pyxel_schema.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pyxel's published schema&lt;/td&gt;
&lt;td&gt;live diff of definition names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Argument bounds&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;my bounds-extraction logic&lt;/td&gt;
&lt;td&gt;real values inside the bundled schema&lt;/td&gt;
&lt;td&gt;tests run against the real schema file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tutorial / doc accuracy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;70+ tutorial &lt;code&gt;.md&lt;/code&gt; examples&lt;/td&gt;
&lt;td&gt;the installed Pyxel Python API&lt;/td&gt;
&lt;td&gt;introspect Pyxel, check every example&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of the post is these three, in order.&lt;/p&gt;




&lt;h3&gt;
  
  
  Layer 1 — Is the bundled schema still fresh?
&lt;/h3&gt;

&lt;p&gt;The tool ships a bundled copy of Pyxel's schema so validation works offline and instantly. The risk is obvious: the day Pyxel adds a model, the bundle is stale and can't validate configs that use it.&lt;/p&gt;

&lt;p&gt;Rather than pretend that never happens, the tool checks &lt;em&gt;at runtime&lt;/em&gt; and tells the user the truth. It fetches Pyxel's published schema and diffs the set of defined model names against the bundle:&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="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;checkSchemaFreshness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundledSchemaText&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PYXEL_SCHEMA_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;bundled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundledSchemaText&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;bundledNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;definitionNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundled&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;upstreamNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;definitionNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;upstream&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;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;upstreamNames&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;bundledNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stale&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="cm"&gt;/* human-readable summary */&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="cm"&gt;/* "couldn't reach Pyxel…" */&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;p&gt;Three states, not two. &lt;code&gt;current&lt;/code&gt; and &lt;code&gt;stale&lt;/code&gt; are the happy and sad paths — but &lt;code&gt;unknown&lt;/code&gt; is the one that matters for trust. If the user is offline, or Pyxel's host is down, or the response is HTML instead of JSON, the tool must not &lt;em&gt;claim&lt;/em&gt; freshness it can't verify. It says so plainly: validation reflects the bundled schema only.&lt;/p&gt;

&lt;p&gt;That third branch is exactly the kind of thing that rots untested, so it's pinned down hard. The freshness suite mocks &lt;code&gt;fetch&lt;/code&gt; and drives every path — superset-is-still-current, singular-vs-plural messaging, five-name truncation with an ellipsis, non-OK HTTP, a rejected promise (offline/CORS), unparseable HTML, and an assertion that the request actually sets &lt;code&gt;cache: "no-store"&lt;/code&gt; so a cached response can't produce a false "current":&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;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reports 'unknown' when upstream returns unparseable JSON&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stubGlobal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;mockFetchResolving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;!doctype html&amp;gt;&amp;lt;html&amp;gt;&lt;/span&gt;&lt;span class="dl"&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;result&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;checkSchemaFreshness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BUNDLED&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&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;unknown&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The invariant being protected isn't "the schema is fresh" — I can't guarantee that. It's &lt;em&gt;"the tool never lies about whether the schema is fresh."&lt;/em&gt; That's a property I fully own, so it gets full coverage.&lt;/p&gt;




&lt;h3&gt;
  
  
  Layer 2 — Do the real schema's bounds still hold?
&lt;/h3&gt;

&lt;p&gt;The tool reads numeric argument bounds (min / max, inclusive / exclusive) out of the schema so it can warn when a value is out of range. That extraction logic has ordinary unit tests against a small synthetic schema — the fast, focused kind you'd expect.&lt;/p&gt;

&lt;p&gt;But synthetic fixtures have the drift problem baked in: they prove my &lt;em&gt;parser&lt;/em&gt; works, not that it still works &lt;em&gt;against Pyxel's actual data&lt;/em&gt;. So a second, smaller set of tests runs the same extractor against the &lt;strong&gt;real bundled &lt;code&gt;pyxel_schema.json&lt;/code&gt;&lt;/strong&gt;:&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;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;extractArgBounds — against the real bundled pyxel_schema.json&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="o"&gt;=&amp;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;realSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schemaPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reads cutoff_wavelength bounds for dark_current_rule07&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="o"&gt;=&amp;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;extractArgBounds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;realSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark_current_rule07&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toMatchObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;cutoff_wavelength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;15.0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reads an exclusive-minimum arg for usaf_illumination&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="o"&gt;=&amp;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;extractArgBounds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;realSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usaf_illumination&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toMatchObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&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="na"&gt;exclusiveMin&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;p&gt;These assert on &lt;em&gt;specific values&lt;/em&gt; from the shipped schema. If a future schema refresh changes &lt;code&gt;cutoff_wavelength&lt;/code&gt;'s range, or flips an inclusive bound to exclusive, this test fails loudly at refresh time — which is precisely when I want to find out, not weeks later from a confused user. It turns a silent data change into a red pipeline. The synthetic tests prove the logic; these pin the logic to reality.&lt;/p&gt;




&lt;h3&gt;
  
  
  Layer 3 — Do the docs still match the actual API?
&lt;/h3&gt;

&lt;p&gt;This is the layer I'm most attached to, because docs are where drift hides best. The tool ships 70+ tutorial files, each with YAML examples showing how to configure a Pyxel model. Prose examples don't get executed, so they rot invisibly: an argument gets renamed upstream, and the tutorial keeps cheerfully showing the old name forever.&lt;/p&gt;

&lt;p&gt;So the CI pipeline installs a &lt;em&gt;pinned real version of Pyxel&lt;/em&gt; and introspects it — no fixtures, the actual package — to discover the ground truth, then checks every tutorial against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;discover_pyxel_models&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return {group: {model_name: {valid_argument_names}}} from installed Pyxel.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;catalog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PIPELINE_GROUPS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;importlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;import_module&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pyxel.models.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isfunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                     &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;detector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VAR_POSITIONAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VAR_KEYWORD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every YAML block in every tutorial is then parsed and checked against that live catalog. A tutorial fails if it references a model that doesn't exist, puts a real model under the wrong pipeline stage, or passes an argument the actual function signature doesn't accept:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;func &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;: invalid argument &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(valid: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two details here I'd happily defend in review. First, there's a guard test asserting introspection discovered more than 40 models — so if an import path changes and &lt;code&gt;discover_pyxel_models()&lt;/code&gt; silently returns almost nothing, the suite fails on &lt;em&gt;that&lt;/em&gt; rather than falsely reporting every tutorial as clean. A drift check that can quietly discover nothing is worse than no check, because it's green &lt;em&gt;and&lt;/em&gt; wrong.&lt;/p&gt;

&lt;p&gt;Second, because these tutorials are partly authored with LLM help, the checker also greps for stray assistant chatter — "Certainly!", "Would you like me to…", "Here are the N Mermaid diagrams" — that has no business in a published tutorial. That's a different kind of drift (content contamination rather than API mismatch), but it lives at the same seam, so it's caught in the same pass.&lt;/p&gt;

&lt;p&gt;The Pyxel version is pinned in CI (&lt;code&gt;PYXEL_VERSION: "2.17.1"&lt;/code&gt;). Bumping it is a deliberate, visible act — and when I bump it, this job tells me &lt;em&gt;immediately&lt;/em&gt; which tutorials the new version just invalidated. Drift becomes a code review, not a surprise.&lt;/p&gt;




&lt;h3&gt;
  
  
  Testing at the seam
&lt;/h3&gt;

&lt;p&gt;Across all three layers, the same principle keeps showing up: &lt;strong&gt;test each invariant at the layer where it actually lives, and mock the layer below it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The freshness checker, the schema loader, and the live telemetry sender all touch the network. None of them hit the network in tests. &lt;code&gt;fetch&lt;/code&gt; (and the YAML parser) are replaced with mocks, so the suite exercises the &lt;em&gt;logic around&lt;/em&gt; the request — cache hit/miss, HTML-instead-of-YAML detection, missing-&lt;code&gt;models&lt;/code&gt; guards, offline fallbacks, the OTLP body shape, the span rate limiter — deterministically, with zero flakiness:&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;function&lt;/span&gt; &lt;span class="nf"&gt;mockFetchResolving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodyText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mockResolvedValue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &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;bodyText&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;This is the same move as mocking the schema loader when testing pure validation logic, and it's why the JS suite runs in a plain Node environment with no browser and no live calls. The network is a &lt;em&gt;seam&lt;/em&gt; — a clean boundary I can substitute at — and testing right at that seam is what makes otherwise-flaky, environment-dependent branches into boring, reliable regression guards that run on every commit.&lt;/p&gt;

&lt;p&gt;The pattern generalizes: find the boundary between the logic you own and the world you don't, and put your test double exactly there. Above it, assert everything. Below it, don't pretend to.&lt;/p&gt;




&lt;h3&gt;
  
  
  Making the gates non-optional
&lt;/h3&gt;

&lt;p&gt;A regression guard nobody enforces is a suggestion. The parts of this codebase that guard against drift — the diagnostics that turn noisy validator errors into friendly messages, the bounds extractor, the shape analysis — are the parts most worth protecting from silent decay, so CI enforces &lt;strong&gt;per-file coverage thresholds&lt;/strong&gt;, not a single blurry project-wide number:&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;thresholds&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;scripts/configShape.js&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="nl"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;95&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="mi"&gt;95&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scripts/modelDiscriminator.js&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="nl"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;95&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="mi"&gt;95&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scripts/schemaBounds.js&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="nl"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&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="mi"&gt;95&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-file gates matter because a project-wide average lets a well-tested module subsidize a rotting one. Pinning each critical file just under its measured coverage means the pipeline fails the moment someone adds an untested branch to &lt;em&gt;that specific file&lt;/em&gt; — the drift-sensitive logic can't quietly lose its safety net behind a healthy-looking global percentage.&lt;/p&gt;

&lt;p&gt;One threshold is deliberately low: the module that wires a real AJV instance to the live schema sits at 35%, because most of it is genuinely exercised only in the browser. Rather than fake coverage with brittle DOM tests, the gate is set honestly to what the pure logic covers, and the rest is documented as manual. Which brings me to the last piece.&lt;/p&gt;




&lt;h3&gt;
  
  
  What's deliberately not automated
&lt;/h3&gt;

&lt;p&gt;Not everything belongs in CI, and pretending it does produces flaky suites that teams learn to ignore. This project draws an explicit line — a &lt;em&gt;method-to-layer split&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automated:&lt;/strong&gt; the deterministic logic — parsing, shape derivation, bounds extraction, error de-duplication, freshness diffing, the network seam. Pure functions with clear inputs and outputs. These run on every commit and gate the merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manually verified:&lt;/strong&gt; the modules that render to or read from the live DOM — schema renderers, validation overlays, the dynamic model loader, the UI components. These are checked by hand in the browser with devtools, where a human eye catches visual and perceptual problems a headless assertion would miss.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is that this line is &lt;em&gt;written down&lt;/em&gt;, in the test README, with the reasoning and the next step named: if the rendered layer ever warrants automation, a headless-browser harness like Playwright (whose auto-waiting removes most timing flakiness) is the natural tool. Until then, "manual" is a documented decision, not an accidental gap. A reader — or future me — can see exactly what's covered, what isn't, and why.&lt;/p&gt;

&lt;p&gt;That honesty is itself a drift defense. The most dangerous test suite is the one that looks more complete than it is.&lt;/p&gt;




&lt;h3&gt;
  
  
  Takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When you don't own the source of truth, drift is the primary bug.&lt;/strong&gt; Frame the whole test strategy around "where can my copy silently disagree with the real thing?" before writing a single assertion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixtures freeze the truth; sometimes you must test against the real thing.&lt;/strong&gt; Run a subset of tests against the real schema and a pinned real dependency, so an upstream change turns into a red pipeline instead of a confused user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check that can silently discover nothing is worse than no check.&lt;/strong&gt; Guard your introspection with a sanity assertion ("&amp;gt;40 models found"), or a broken import will report everything as clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test at the seam.&lt;/strong&gt; Mock the network (or DOM, or external API) right at its boundary; assert fully on the logic above it. Deterministic branches become reliable guards instead of flaky ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce per-file, not project-wide, coverage.&lt;/strong&gt; An average lets healthy modules hide rotting ones. Pin the drift-sensitive files individually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down what you &lt;em&gt;don't&lt;/em&gt; automate.&lt;/strong&gt; A documented manual-test surface is a decision; an undocumented one is a gap. The suite that overstates its own completeness is the one that eventually bites you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Drift is quiet by nature — it's the bug that arrives with no failing test and no commit to blame. The only defense is to build the checks that go looking for it on purpose, put each one where it can actually see, and let CI insist on them every single time.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>I Instrumented a System the OTel GenAI Conventions Weren't Built For - Here's Where They Broke</title>
      <dc:creator>Doby Baxter</dc:creator>
      <pubDate>Tue, 14 Jul 2026 01:55:28 +0000</pubDate>
      <link>https://dev.to/dobybaxter127/i-instrumented-a-system-the-otel-genai-conventions-werent-built-for-heres-where-they-broke-40dh</link>
      <guid>https://dev.to/dobybaxter127/i-instrumented-a-system-the-otel-genai-conventions-werent-built-for-heres-where-they-broke-40dh</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I instrumented a deterministic, content-blind AI workflow enforcer with the emerging OpenTelemetry GenAI semantic conventions. It technically worked, but pinched in six specific places — each one exposing the same hidden assumption: that a GenAI system is, by definition, a &lt;em&gt;model invoker&lt;/em&gt;. This post catalogs the six frictions, shows the design I shipped instead, and follows one friction into a live semantic-conventions discussion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll get out of it&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A concrete map of where the GenAI conventions assume a model sits at the center of every span&lt;/li&gt;
&lt;li&gt;A reusable pattern for instrumenting orchestration / governance / policy layers that don't call models&lt;/li&gt;
&lt;li&gt;Why "refused" must not be encoded as "errored," and how to fix that in one line&lt;/li&gt;
&lt;li&gt;How a friction log — not a formal proposal — can still move a standard&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;The system under instrumentation&lt;/li&gt;
&lt;li&gt;The six frictions (at a glance)&lt;/li&gt;
&lt;li&gt;The frictions in detail&lt;/li&gt;
&lt;li&gt;What I shipped instead: own the namespace&lt;/li&gt;
&lt;li&gt;The pattern underneath&lt;/li&gt;
&lt;li&gt;Where one friction ended up&lt;/li&gt;
&lt;li&gt;Takeaways&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  The system under instrumentation
&lt;/h3&gt;

&lt;p&gt;First, a disambiguation, because "LLM router" is an overloaded term.&lt;/p&gt;

&lt;p&gt;Most projects with that name are &lt;strong&gt;model-selection routers&lt;/strong&gt; — they pick between a cheap and an expensive model based on query complexity or cost. This system is &lt;em&gt;not&lt;/em&gt; that. It's a &lt;strong&gt;topology-enforcement layer&lt;/strong&gt;: given structured metadata about an interaction, it answers exactly one question —&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Is this workflow transition permitted?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;— independent of model choice or content. It validates workflow topology at load time, enforces invocation limits, and returns one of three terminal decisions: &lt;code&gt;PROCEED&lt;/code&gt;, &lt;code&gt;REFUSE&lt;/code&gt;, or &lt;code&gt;PAUSE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The defining property, for instrumentation purposes: it is &lt;strong&gt;content-blind by design.&lt;/strong&gt; It never sees a prompt, never calls a provider, never generates a token. It observes metadata and structure — nothing else.&lt;/p&gt;

&lt;p&gt;The experiment wrapped two operations in OTel spans:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;validate_workflow_config()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Static structural validation, &lt;em&gt;before&lt;/em&gt; anything runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WorkflowEngine.evaluate()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The per-transition permission decision&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule I set myself: use the existing GenAI conventions everywhere they fit, and write down every place they don't. That log of mismatches is the heart of this post.&lt;/p&gt;




&lt;h3&gt;
  
  
  The six frictions (at a glance)
&lt;/h3&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;Friction&lt;/th&gt;
&lt;th&gt;The hidden assumption it exposes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;No operation name for validation&lt;/td&gt;
&lt;td&gt;Observability begins only when &lt;em&gt;inference&lt;/em&gt; begins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;provider.name&lt;/code&gt; assumes a provider&lt;/td&gt;
&lt;td&gt;Every GenAI span calls a model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;No vocabulary for topology&lt;/td&gt;
&lt;td&gt;The unit of interest is a &lt;em&gt;conversation&lt;/em&gt;, not a graph position&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;A refusal reads as an error&lt;/td&gt;
&lt;td&gt;The only non-success outcome is &lt;em&gt;failure&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Content observability is the default&lt;/td&gt;
&lt;td&gt;Instrumentation means watching prompts and completions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;No trace-parenting for standalone runs&lt;/td&gt;
&lt;td&gt;Workflow spans are children of a model span&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read together, they all say one thing: &lt;strong&gt;the conventions model a GenAI system as a model invoker.&lt;/strong&gt; The detail sections below walk each one; skip to What I shipped instead if you just want the design.&lt;/p&gt;




&lt;h3&gt;
  
  
  The frictions in detail
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Friction 1 — There's no operation name for validation
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; The current &lt;code&gt;gen_ai.operation.name&lt;/code&gt; values all describe runtime inference: &lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;generate_content&lt;/code&gt;, &lt;code&gt;invoke_agent&lt;/code&gt;, &lt;code&gt;retrieval&lt;/code&gt;, &lt;code&gt;execute_tool&lt;/code&gt;. None describe &lt;em&gt;static structural validation&lt;/em&gt;, so the only accurate value was a non-standard one like &lt;code&gt;validate_workflow_config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; The conventions treat GenAI observability as beginning when model activity begins. But for deterministic orchestration systems, much of the real safety work happens &lt;em&gt;before&lt;/em&gt; execution starts. The spec has no vocabulary for that phase at all.&lt;/p&gt;

&lt;h4&gt;
  
  
  Friction 2 — &lt;code&gt;gen_ai.provider.name&lt;/code&gt; assumes a provider exists
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; The system has no provider. It doesn't call OpenAI, Anthropic, Gemini, or a local inference server, so the attribute is simply omitted — leaving the spans technically incomplete relative to the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; That's fine when every GenAI span lives inside a model invocation. But it presumes the &lt;em&gt;category&lt;/em&gt; of system in scope: model invokers. Orchestration, governance, and policy-enforcement layers that sit &lt;em&gt;adjacent&lt;/em&gt; to models have no home here — LangGraph's graph-execution layer, Temporal-style workflow runtimes wrapping LLM steps, or guardrail frameworks like NeMo Guardrails that gate execution without generating anything.&lt;/p&gt;

&lt;h4&gt;
  
  
  Friction 3 — Workflow topology has no vocabulary
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; A meaningful evaluation span needs three facts: the current container, the requested transition target, and the terminal decision. No &lt;code&gt;gen_ai.*&lt;/code&gt; attribute represents any of them. The nearest neighbor, &lt;code&gt;gen_ai.conversation.id&lt;/code&gt;, describes conversational identity — not structural position in a graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; Using conversation semantics for topology would produce actively misleading telemetry. This is the clearest point where the model shows its conversational bias: the spec can describe &lt;em&gt;who is talking&lt;/em&gt;, but not &lt;em&gt;where in the graph you are&lt;/em&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Friction 4 — A refusal is not an error
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; The system emits typed structural refusal reasons — &lt;code&gt;UNKNOWN_CONTAINER&lt;/code&gt;, &lt;code&gt;INVALID_TRANSITION&lt;/code&gt;, &lt;code&gt;MAX_INVOCATION_EXCEEDED&lt;/code&gt;, &lt;code&gt;CONTAINER_REENTRY_BLOCKED&lt;/code&gt;. The obvious attribute, &lt;code&gt;error.type&lt;/code&gt;, describes &lt;em&gt;failures&lt;/em&gt; of execution: timeouts, API errors, exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; A refusal is the opposite of a failure — the system is working &lt;em&gt;correctly&lt;/em&gt; when it refuses an invalid transition. Recording enforcement outcomes as errors poisons every downstream error-rate dashboard. "The system failed" and "the system said no, on purpose" are fundamentally different, and the conventions can't tell them apart.&lt;/p&gt;

&lt;h4&gt;
  
  
  Friction 5 — Content observability is treated as the default
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; The conventions center on input/output messages, prompts, completions, tool calls, and retrieval context. This system observes &lt;em&gt;none&lt;/em&gt; of those, by design. Its stance is structural, not semantic: it traces topology, transition legality, and enforcement outcomes while staying blind to user content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; That stance is a privacy &lt;em&gt;and&lt;/em&gt; an architecture decision. But the spec implicitly treats content observability as the natural shape of GenAI instrumentation, with no notion of a system whose entire telemetry story is structural.&lt;/p&gt;

&lt;h4&gt;
  
  
  Friction 6 — Standalone evaluations have no trace-parenting model
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; Every evaluation produced an isolated trace (&lt;code&gt;parent_id: null&lt;/code&gt;), because there was no upstream model-invocation span to inherit context from — and the conventions imply workflow spans exist as children of model or agent spans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; The result is observability fragmentation: config validation and the evaluations that depend on it become disconnected islands instead of one structural lifecycle. There's no guidance for relating these spans when no model runtime sits above them.&lt;/p&gt;




&lt;h3&gt;
  
  
  What I shipped instead: own the namespace
&lt;/h3&gt;

&lt;p&gt;The conclusion wasn't "force the fit." It was: &lt;strong&gt;don't borrow &lt;code&gt;gen_ai.*&lt;/code&gt; at all.&lt;/strong&gt; The shipped instrumentation defines its own documented, versioned convention surface under a &lt;code&gt;wfrouter.*&lt;/code&gt; namespace (schema version 1.0.0), on four design rules.&lt;/p&gt;

&lt;h4&gt;
  
  
  The four design rules
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;1. Zero hard dependency.&lt;/strong&gt; The engine never imports the instrumentation; the instrumentation imports the engine. If &lt;code&gt;opentelemetry-api&lt;/code&gt; isn't installed, every function degrades to a no-op and behavior is unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The engine stays pure.&lt;/strong&gt; Nothing is instrumented &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;evaluate()&lt;/code&gt;. The wrapper traces it from the outside, so the engine stays deterministic, stateless, and trivially unit-testable with no telemetry in the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Own the namespace, own the versioning.&lt;/strong&gt; Every attribute lives under &lt;code&gt;wfrouter.*&lt;/code&gt;. When the still-evolving &lt;code&gt;gen_ai.*&lt;/code&gt; conventions change, nothing moves underneath this schema. If interop ever justifies it, &lt;code&gt;gen_ai.*&lt;/code&gt; attributes can be emitted &lt;em&gt;alongside&lt;/em&gt;, additively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A refusal sets span status &lt;code&gt;OK&lt;/code&gt;.&lt;/strong&gt; All three terminal states — &lt;code&gt;PROCEED&lt;/code&gt;, &lt;code&gt;REFUSE&lt;/code&gt;, &lt;code&gt;PAUSE&lt;/code&gt; — are valid, successful outcomes. Status &lt;code&gt;ERROR&lt;/code&gt; is reserved for genuine failures: a config that fails validation, or an unexpected exception. That's Friction 4, resolved in code.&lt;/p&gt;

&lt;h4&gt;
  
  
  The convention surface
&lt;/h4&gt;

&lt;p&gt;Three low-cardinality span names — the container name is always an &lt;em&gt;attribute&lt;/em&gt;, never part of a span name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wfrouter.evaluate
wfrouter.validate_config
wfrouter.analyze_topology
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attributes on the &lt;code&gt;wfrouter.evaluate&lt;/code&gt; span:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wfrouter.container&lt;/code&gt;, &lt;code&gt;wfrouter.requested_action&lt;/code&gt;, &lt;code&gt;wfrouter.previous_state&lt;/code&gt;, &lt;code&gt;wfrouter.invocation_depth&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Decision&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wfrouter.decision&lt;/code&gt;, &lt;code&gt;wfrouter.refused&lt;/code&gt;, &lt;code&gt;wfrouter.reason_code&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Correlation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wfrouter.correlation_id&lt;/code&gt; — app-level, kept distinct from the OTel trace ID so you can join on either&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two deliberate cardinality decisions worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The raw transition history is &lt;strong&gt;not&lt;/strong&gt; emitted — it's unbounded. Only &lt;code&gt;wfrouter.transition_history.length&lt;/code&gt; is recorded.&lt;/li&gt;
&lt;li&gt;Topology issues become span &lt;strong&gt;events&lt;/strong&gt; (&lt;code&gt;wfrouter.topology_issue&lt;/code&gt;), each carrying a severity and a stable, greppable code — not one attribute per issue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And two metric instruments, both with bounded attribute sets so aggregation stays safe:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instrument&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Attributes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wfrouter.evaluations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;decision&lt;/code&gt;, &lt;code&gt;reason_code&lt;/code&gt;, &lt;code&gt;container&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wfrouter.invocation_depth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;histogram&lt;/td&gt;
&lt;td&gt;&lt;code&gt;container&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That first counter gives you things like &lt;em&gt;"REFUSE rate by container"&lt;/em&gt; for free.&lt;/p&gt;

&lt;h4&gt;
  
  
  Using it
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"llm-workflow-router[otel]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;router.engine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WorkflowEngine&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;router.observability.otel&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ObservableEngine&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ObservableEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WorkflowEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ObservableEngine&lt;/code&gt; is a drop-in for &lt;code&gt;WorkflowEngine&lt;/code&gt; at any call site that wants telemetry. The pure engine underneath never changes.&lt;/p&gt;




&lt;h3&gt;
  
  
  The pattern underneath
&lt;/h3&gt;

&lt;p&gt;Individually, each friction is small. Together they point at one thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The current GenAI conventions model AI systems primarily as &lt;strong&gt;systems of generation&lt;/strong&gt; — prompts in, completions out, tools called, context retrieved.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a valid model for a large slice of the ecosystem. But a whole class of systems already exists that participates in AI execution pipelines &lt;em&gt;without generating anything&lt;/em&gt;: orchestration engines, workflow runtimes, safety gates, validation layers, execution governors. They need observability too, and their semantics are structural, not conversational. Today, each of them has to do what I did — step outside the spec and own a namespace.&lt;/p&gt;

&lt;p&gt;A friction log is valuable precisely because it makes a spec's hidden assumptions visible. This one suggests the conventions may eventually need to separate three things now blurred together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inference&lt;/strong&gt; semantics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structural orchestration&lt;/strong&gt; semantics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Governance / enforcement&lt;/strong&gt; semantics&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Where one friction ended up
&lt;/h3&gt;

&lt;p&gt;Here's the part I didn't expect.&lt;/p&gt;

&lt;p&gt;I left the friction log as a comment on an open semantic-conventions discussion about agentic authorization — a proposal introducing producer-emitted decision signals like &lt;code&gt;gen_ai.agent.trust_score&lt;/code&gt; and &lt;code&gt;gen_ai.agent.drift_score&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A practitioner in that thread, building the producer side in production, surfaced a failure mode you only see once a system is running: &lt;strong&gt;a producer's scoring function isn't fixed.&lt;/strong&gt; Models get re-fit, thresholds move, feature sets change. The emitted score then shifts for a reason that has &lt;em&gt;nothing to do with the agent's behavior&lt;/em&gt; — and a consumer correlating scores against outcomes can't tell whether the agent changed or the measurement changed.&lt;/p&gt;

&lt;p&gt;That clicked against my frictions, and I framed it the only way I could:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A signal is only worth anything if you can tell when the ruler itself changed length.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The shape the discussion converged on is a minimal provenance pattern — an opaque, producer-scoped &lt;strong&gt;method token&lt;/strong&gt; next to each score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gen_ai.agent.trust_score.method
gen_ai.agent.drift_score.method
gen_ai.agent.scan_verdict.method
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consumers never parse the token; they compare it for equality only. Same token → scores stay comparable. Changed token → the measurement function was replaced, so reset your baseline instead of chasing a phantom drift event. One disciplined pattern instead of three special cases — and it generalizes to &lt;em&gt;any&lt;/em&gt; derived, evolving signal emitted for downstream correlation.&lt;/p&gt;

&lt;p&gt;An observation from an odd little friction log, sharpened by someone hitting the same wall from the production side, is now part of a live convention discussion. That's standards work at its best: a practitioner names a blind spot, the principle generalizes, and the fix is smaller than anyone expected.&lt;/p&gt;




&lt;h3&gt;
  
  
  Takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instrument the weird system you have.&lt;/strong&gt; Edge cases are exactly what an in-development spec needs. A mismatch is information — write it down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refusals are not errors — encode that in span status.&lt;/strong&gt; Keep enforcement outcomes out of your error semantics from day one. &lt;code&gt;REFUSE&lt;/code&gt; with status &lt;code&gt;OK&lt;/code&gt; is one line of code, and it saves every error-rate dashboard downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a spec doesn't fit, own a namespace instead of forcing the fit.&lt;/strong&gt; A small, documented, versioned surface is more honest and more stable than misusing someone else's vocabulary — and it leaves room to emit standard attributes alongside later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard your cardinality on purpose.&lt;/strong&gt; No container names in span names, history &lt;em&gt;length&lt;/em&gt; not the raw list, bounded metric attribute sets. Decisions, not defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Friction logs are a real contribution path.&lt;/strong&gt; You don't need to author a proposal to move a standard. Sometimes you just need to describe, precisely, where the shoe pinched.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The GenAI semantic conventions are still marked experimental — which makes this the cheapest moment to give this kind of feedback. If you're instrumenting anything AI-adjacent that isn't a model invoker, go log your frictions. The spec gets better every time someone maps its edges.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opentelemetry</category>
      <category>devops</category>
      <category>observability</category>
    </item>
    <item>
      <title>GitLab vs GitHub in 2026: Integrated Platform vs. Composable Ecosystem</title>
      <dc:creator>Doby Baxter</dc:creator>
      <pubDate>Mon, 13 Jul 2026 20:33:28 +0000</pubDate>
      <link>https://dev.to/dobybaxter127/gitlab-vs-github-in-2026-integrated-platform-vs-composable-ecosystem-5dm9</link>
      <guid>https://dev.to/dobybaxter127/gitlab-vs-github-in-2026-integrated-platform-vs-composable-ecosystem-5dm9</guid>
      <description>&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%2Fvtoch038ocy7i5vksh2t.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%2Fvtoch038ocy7i5vksh2t.png" alt="GitLab and GitHub logos shown side by side for comparison." width="799" height="247"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pick GitLab or GitHub in 2026 and you're not really choosing between two Git hosts — you're choosing between two philosophies about what a development platform should be. The old comparison ("GitLab has CI/CD built in, GitHub needs external tools") is out of date. GitHub has native CI/CD, native security scanning, and a native container registry now. The real difference is architectural: GitLab bundles the whole DevSecOps lifecycle into one integrated application, while GitHub gives you a strong core and an enormous ecosystem to compose the rest yourself.&lt;/p&gt;

&lt;p&gt;This guide walks through where that split actually matters — repository workflow, CI/CD, security, project management, and hosting — and tries to be fair about the trade-offs, because both platforms are genuinely good and the "right" answer depends entirely on your team.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core distinction
&lt;/h2&gt;

&lt;p&gt;One idea explains most of the differences below, so it's worth stating up front.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitLab is integration.&lt;/strong&gt; It's a single application where every stage — plan, code, build, test, secure, deploy, monitor — shares one data model and one interface. A security finding in a pipeline links to the merge request that introduced it, which links to the issue that requested the feature. Nothing is stitched together because it was never separate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub is composition.&lt;/strong&gt; It gives you best-in-class code hosting, pull requests, and the largest developer community anywhere, then lets you assemble the rest of your toolchain from a marketplace of 15,000+ Actions and integrations. You wire in the CI/CD, security, and project management pieces you want.&lt;/p&gt;

&lt;p&gt;Neither is wrong. Integration reduces context-switching and simplifies governance; composition maximizes flexibility and lets you swap any piece. Almost every concrete difference downstream is a consequence of this one choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository management
&lt;/h2&gt;

&lt;p&gt;This is the closest to a tie, because both platforms are built on Git and both do the fundamentals extremely well: branching, protected branches, commit history, tags, and a capable web UI.&lt;/p&gt;

&lt;p&gt;The one naming difference worth knowing: GitLab calls them &lt;strong&gt;merge requests (MRs)&lt;/strong&gt; and GitHub calls them &lt;strong&gt;pull requests (PRs)&lt;/strong&gt;. They're the same concept — a proposed change with review, discussion, and approval before it lands on a protected branch. GitLab's MRs are wired more tightly into its wider DevOps data model (an MR can show pipeline status, security scan results, and linked issues in one view); GitHub's PRs sit at the center of a larger collaboration ecosystem and benefit from that community gravity.&lt;/p&gt;

&lt;p&gt;For day-to-day source control, you will be happy on either. The differences show up once you move past the repository itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI/CD
&lt;/h2&gt;

&lt;p&gt;This is where the platforms diverge in feel, even though both are now fully native.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitLab CI/CD&lt;/strong&gt; is defined in a &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; file and executed by GitLab Runners. It's been native since the beginning, so it's deeply woven into the rest of the platform — pipeline results, environments, and deployments all live in the same interface as your code and issues. Teams with complex delivery workflows often find the unified model reduces configuration drift.&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="c1"&gt;# .gitlab-ci.yml — a simple three-stage pipeline&lt;/span&gt;
&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;

&lt;span class="na"&gt;build-job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Compiling the project..."&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./build.sh&lt;/span&gt;

&lt;span class="na"&gt;test-job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&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;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Running tests..."&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./run-tests.sh&lt;/span&gt;

&lt;span class="na"&gt;deploy-job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Deploying to production..."&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;$CI_COMMIT_BRANCH&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"main"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt; uses YAML workflow files triggered by repository events, executed on GitHub-hosted or self-hosted runners. Its superpower is the marketplace — thousands of prebuilt, reusable actions mean you rarely write automation from scratch. For simple-to-moderate workflows in 2026, Actions has fully closed the historical gap with GitLab; for very complex pipelines, GitLab's directed-acyclic-graph execution model still has an edge.&lt;/p&gt;

&lt;p&gt;One 2026 caveat worth flagging for teams that self-host their build infrastructure: GitHub planned to start charging for self-hosted runner minutes in early 2026, and after community pushback the plan was postponed but not cancelled. If your build strategy leans on self-hosted Actions runners, that pricing uncertainty is worth planning around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest summary:&lt;/strong&gt; GitLab for deeply integrated, complex CI/CD with predictable self-hosted economics; GitHub Actions for speed to first pipeline and an unmatched library of reusable automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security scanning
&lt;/h2&gt;

&lt;p&gt;This is the claim most in need of updating from a few years ago. It is no longer true that GitHub needs external tools for security — both platforms ship meaningful scanning natively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt; includes Dependabot (dependency alerts and automated fix PRs) and secret scanning, with much of it free for public repositories. Its deeper static analysis (CodeQL) and advanced enterprise security features come through a separate, paid security offering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitLab&lt;/strong&gt; bundles a broader set of scan types — SAST, DAST, dependency scanning, container scanning, and more — into its paid tiers without per-scanner licensing, and surfaces all results inside the same interface as your code and merge requests. For teams in regulated industries that have to &lt;em&gt;prove&lt;/em&gt; governance, that single unified audit trail — one dashboard tying vulnerabilities to the exact change that introduced them — is often the deciding factor.&lt;/p&gt;

&lt;p&gt;So the real distinction isn't "has security vs. doesn't." It's &lt;em&gt;bundled-and-unified&lt;/em&gt; (GitLab) vs. &lt;em&gt;strong-core-plus-add-ons&lt;/em&gt; (GitHub). Which matters more depends on whether your pain is procurement-and-compliance or flexibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project management
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitLab&lt;/strong&gt; ships fairly mature agile tooling natively — issues, boards, milestones, epics, and value-stream dashboards — as part of the same application. For teams that want planning to live next to code without a separate tool, it works well as a standalone-ish project manager.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt; offers Issues, Discussions, and Projects, which have improved substantially and are genuinely capable for many teams. But organizations running complex agile processes (deep epic hierarchies, roadmaps) still frequently integrate a dedicated tool like Jira. That's consistent with the composition philosophy: GitHub gives you a solid base and expects you to extend it if your process is heavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pages and hosting
&lt;/h2&gt;

&lt;p&gt;Both platforms offer free static site hosting from a repository — &lt;strong&gt;GitLab Pages&lt;/strong&gt; and &lt;strong&gt;GitHub Pages&lt;/strong&gt; — and both are excellent for documentation, portfolios, and project sites. GitLab Pages integrates directly with GitLab CI/CD, so the same pipeline that builds your project can publish the site as a deploy stage. GitHub Pages is famously frictionless for straightforward static sites and benefits from the surrounding ecosystem. Both are hard to beat at their price of zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick side-by-side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;GitLab&lt;/th&gt;
&lt;th&gt;GitHub&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core philosophy&lt;/td&gt;
&lt;td&gt;Integrated single application&lt;/td&gt;
&lt;td&gt;Composable ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;Native (&lt;code&gt;.gitlab-ci.yml&lt;/code&gt;), deeply integrated&lt;/td&gt;
&lt;td&gt;Native (Actions), huge marketplace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security scanning&lt;/td&gt;
&lt;td&gt;Broad suite bundled into paid tiers, unified results&lt;/td&gt;
&lt;td&gt;Dependabot + secret scanning native; CodeQL/advanced via paid add-on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project management&lt;/td&gt;
&lt;td&gt;Mature native agile (epics, boards, value stream)&lt;/td&gt;
&lt;td&gt;Capable native Projects; heavy agile often adds Jira&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community&lt;/td&gt;
&lt;td&gt;~30M+ users, enterprise-deep&lt;/td&gt;
&lt;td&gt;100M+ users, largest open-source community&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosting&lt;/td&gt;
&lt;td&gt;Full DevSecOps suite, single installer&lt;/td&gt;
&lt;td&gt;Enterprise Server (paid license + infra)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static hosting&lt;/td&gt;
&lt;td&gt;GitLab Pages (CI/CD-integrated)&lt;/td&gt;
&lt;td&gt;GitHub Pages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  So which should you choose?
&lt;/h2&gt;

&lt;p&gt;Reduce it to the philosophy and the answer usually falls out on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose GitLab&lt;/strong&gt; if you want one vendor for the entire lifecycle — CI/CD, security, compliance, registries, releases, monitoring — with a unified data model and the option to self-host the whole suite. It's especially compelling for regulated industries and platform-engineering teams who value integrated governance over assembling their own stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose GitHub&lt;/strong&gt; if you want the largest community, the deepest third-party ecosystem, best-in-class open-source collaboration, and the flexibility to compose your own toolchain. It's the default for open-source projects and for teams that would rather pick best-of-breed pieces than adopt one integrated platform.&lt;/p&gt;

&lt;p&gt;A fair caveat on GitLab, since this guide has been evenhanded elsewhere: packing the entire lifecycle into one product makes the interface dense, and teams newer to DevOps sometimes find it overwhelming to navigate. Breadth has a usability cost. GitHub's narrower core is easier to pick up on day one.&lt;/p&gt;

&lt;p&gt;Both are strong platforms. The question isn't which is better in the abstract — it's whether your team would rather have one integrated system or the freedom to assemble the best individual pieces. Answer that, and you've answered the whole comparison.&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>github</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Making Architecture Diagrams Tell the Truth: Trust Boundaries, Data Flow, and the Things We Leave Out</title>
      <dc:creator>Doby Baxter</dc:creator>
      <pubDate>Mon, 13 Jul 2026 20:32:17 +0000</pubDate>
      <link>https://dev.to/dobybaxter127/making-architecture-diagrams-tell-the-truth-trust-boundaries-data-flow-and-the-things-we-leave-dm6</link>
      <guid>https://dev.to/dobybaxter127/making-architecture-diagrams-tell-the-truth-trust-boundaries-data-flow-and-the-things-we-leave-dm6</guid>
      <description>&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%2Fp8gvuk2zs45i9zpe039o.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%2Fp8gvuk2zs45i9zpe039o.png" alt="Symbolic architecture diagram illustrating trust boundaries, data flow, and hidden systems outside the main architecture." width="799" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most architecture diagrams are optimistic. They show the happy path: a client talks to a service, the service talks to a database, arrows point in tidy directions. What they usually leave out is where the risk actually lives — which components hold sensitive data, where trust changes hands, which team is accountable for what, and where data quietly gets copied to a third place nobody remembers to secure.&lt;/p&gt;

&lt;p&gt;That omission isn't cosmetic. As a system grows, the parts that get left off the diagram tend to be exactly the parts that cause incidents: the logging pipeline shipping PII to an analytics vendor, the cache holding session tokens, the "internal" service that's actually reachable from the internet. A diagram that hides those things doesn't just fail to help — it actively misleads the people making decisions from it.&lt;/p&gt;

&lt;p&gt;This piece lays out a small set of principles for drawing system diagrams that make responsibility, data movement, and trust boundaries explicit, and then walks the same example system through three stages of growth so you can see how the risk surface expands as the architecture does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a component label should answer
&lt;/h2&gt;

&lt;p&gt;Every box in a diagram represents a responsibility. If the label is vague, the accountability is vague too. A well-defined component should let a reader answer three questions without asking you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What does it do?&lt;/strong&gt; One function, stated plainly. "Auth service," not "backend."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What data does it control?&lt;/strong&gt; Especially whether that data is sensitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who owns it?&lt;/strong&gt; Which team, or which external provider, is responsible when it breaks or leaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"User Service" is a weak label. "User Service (Auth + PII, owned by Team A)" is a strong one — it tells you the function, the data sensitivity, and the owner in a single line. The extra words are where the accountability lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make data flow directional and typed
&lt;/h2&gt;

&lt;p&gt;Arrows are claims about communication, and the claim should be specific. Two conventions carry most of the weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solid arrows&lt;/strong&gt; for synchronous calls — the caller waits for a response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashed arrows&lt;/strong&gt; for asynchronous or background flows — events, log shipping, replication.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every arrow needs a direction, and ideally a label describing &lt;em&gt;what moves&lt;/em&gt; along it. "Read/Write (PII)" tells a reviewer something that a bare arrow doesn't: that this path carries sensitive data and therefore deserves stricter controls. Undocumented data movement is where hidden risk accumulates, because you can't protect a flow you haven't drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the trust boundaries
&lt;/h2&gt;

&lt;p&gt;A trust boundary is any line where the level of trust in the input changes — the edge between the public internet and your internal network, between your services and a third-party API, between an untrusted user-facing layer and a privileged internal one. These boundaries are the most security-relevant lines on the whole diagram, because they mark where authentication, authorization, and validation &lt;em&gt;must&lt;/em&gt; happen.&lt;/p&gt;

&lt;p&gt;If your diagram doesn't show trust boundaries, it's not showing where enforcement is required. Draw them as explicit enclosures, and make it obvious which components sit inside which zone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make persistence and duplication visible
&lt;/h2&gt;

&lt;p&gt;Storage is an architectural decision, not a background detail. Databases, caches, replicated stores, and log sinks all hold data, and every one of them is a place that data can leak from or be retained longer than intended. Label them, mark which ones hold sensitive data, and — critically — &lt;strong&gt;show where data is duplicated.&lt;/strong&gt; A record that exists in the primary database, the cache, the analytics warehouse, and the log aggregator now has four places to secure and four retention policies to reason about. If the diagram only shows one, the other three are invisible risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three stages of the same system
&lt;/h2&gt;

&lt;p&gt;The clearest way to see these principles pay off is to watch a system grow. Here's the same application at three levels of scale. Notice that each step buys capability and spends it on complexity — more communication paths, more data stores, more owners, more audit surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 1: the contained monolith
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    Client([Client])
    App[Application Server&amp;lt;br/&amp;gt;Auth + API + Business Logic&amp;lt;br/&amp;gt;Owner: Team A]
    DB[(Primary Database&amp;lt;br/&amp;gt;User Data: PII)]
    Logs[(Internal Logs&amp;lt;br/&amp;gt;Audit Trail)]

    Client --&amp;gt;|HTTPS, token auth| App
    App --&amp;gt;|Read/Write, strict access| DB
    App -.-&amp;gt;|Events, PII redacted| Logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One deployment unit, one primary data store, one owner. Enforcement is concentrated in a single place, data replication is minimal, and auditing is simple because there's only one path in and one team accountable. This is the model with the smallest risk surface — everything sensitive sits behind one trust boundary that one team controls. It scales poorly, but it's honest and easy to reason about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2: horizontally scaled with shared services
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    Client([Client])
    LB[Load Balancer&amp;lt;br/&amp;gt;Public Entry]
    App1[App Instance 1&amp;lt;br/&amp;gt;Auth + API]
    App2[App Instance 2&amp;lt;br/&amp;gt;Auth + API]
    Cache[(Cache Layer&amp;lt;br/&amp;gt;Session / Hot Data)]
    DB[(Primary Database&amp;lt;br/&amp;gt;User Data: PII)]
    Logs[(Centralized Logging&amp;lt;br/&amp;gt;Audit + Monitoring)]

    Client --&amp;gt;|HTTPS| LB
    LB --&amp;gt; App1
    LB --&amp;gt; App2
    App1 --&amp;gt;|Read/Write| DB
    App2 --&amp;gt;|Read/Write| DB
    App1 --&amp;gt;|Cached data| Cache
    App2 --&amp;gt;|Cached data| Cache
    App1 -.-&amp;gt;|Events| Logs
    App2 -.-&amp;gt;|Events| Logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now there are multiple runtime instances behind a load balancer, plus dedicated caching and logging. Scalability improves — but notice what else changed. Session data now lives in a cache as well as the database, so PII-adjacent data has two homes. Shared state introduces consistency questions. Responsibility starts to fragment across more moving parts. The trust boundary still holds, but there's more inside it to keep track of.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3: distributed microservices
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    Client([Client])
    GW[API Gateway&amp;lt;br/&amp;gt;Auth + Routing + Rate Limiting]
    SvcA[Service A&amp;lt;br/&amp;gt;User Management&amp;lt;br/&amp;gt;Owner: Team A]
    SvcB[Service B&amp;lt;br/&amp;gt;Orders&amp;lt;br/&amp;gt;Owner: Team B]
    SvcC[Service C&amp;lt;br/&amp;gt;Payments&amp;lt;br/&amp;gt;Owner: Team C]
    MQ[[Message Queue&amp;lt;br/&amp;gt;Async Events]]
    UserDB[(User DB&amp;lt;br/&amp;gt;PII)]
    OrdersDB[(Orders DB&amp;lt;br/&amp;gt;Transactional)]
    PayDB[(Payments DB&amp;lt;br/&amp;gt;Financial)]
    Ext[External Payment Processor&amp;lt;br/&amp;gt;Third Party]
    Logs[(Centralized Logging)]

    Client --&amp;gt;|HTTPS| GW
    GW --&amp;gt; SvcA
    GW --&amp;gt; SvcB
    SvcA --&amp;gt; UserDB
    SvcB --&amp;gt; OrdersDB
    SvcB -.-&amp;gt;|Publish event| MQ
    MQ -.-&amp;gt;|Consume event| SvcC
    SvcC --&amp;gt; PayDB
    SvcC --&amp;gt;|External API call| Ext
    SvcA -.-&amp;gt; Logs
    SvcB -.-&amp;gt; Logs
    SvcC -.-&amp;gt; Logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Service isolation and independent data ownership are real benefits — each team owns its service and its database. But the ethical and security surface has expanded proportionally. Data is now duplicated across services. There's asynchronous movement through a message queue that's easy to under-secure. Payments data crosses a trust boundary to an external processor. Ownership is spread across three teams, which means an audit now requires coordinating three sets of answers. Every one of those is a real property of the system — and every one of them is only manageable if it's actually drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blind spots to check for
&lt;/h2&gt;

&lt;p&gt;Whatever your architecture, these are the things most commonly missing from the diagram — and disproportionately likely to be where incidents originate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Logging and observability pipelines (they carry more sensitive data than people expect)&lt;/li&gt;
&lt;li&gt;Third-party analytics and their data flows&lt;/li&gt;
&lt;li&gt;Data replication and backup copies&lt;/li&gt;
&lt;li&gt;Implicit trust relationships — the "internal" call that isn't authenticated because "it's internal"&lt;/li&gt;
&lt;li&gt;External APIs that handle sensitive information&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a component is missing from your diagram, it's not being reasoned about. And the components teams most often forget to draw are frequently the highest-risk ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;System design isn't only about scalability and performance. It's about responsibility, visibility, and sustainability over time. As a system scales, complexity rises; as complexity rises, accountability diffuses and the data surface area grows. A diagram that makes those dynamics visible — explicit trust boundaries, typed and directional data flow, labeled persistence, named ownership — turns architecture into something you can actually govern, not just something you can deploy. Clarity in the diagram is the first line of defense for everything downstream of it.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>systemdesign</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Validate Your LLM Workflow Before It Runs (Python + YAML Topology Checks)</title>
      <dc:creator>Doby Baxter</dc:creator>
      <pubDate>Mon, 13 Jul 2026 20:30:50 +0000</pubDate>
      <link>https://dev.to/dobybaxter127/validate-your-llm-workflow-before-it-runs-python-yaml-topology-checks-35l1</link>
      <guid>https://dev.to/dobybaxter127/validate-your-llm-workflow-before-it-runs-python-yaml-topology-checks-35l1</guid>
      <description>&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%2Fqnrajlha8ggafb53ijwc.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%2Fqnrajlha8ggafb53ijwc.png" alt="Workflow graph highlighting an invalid execution path caught by topology validation before execution." width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your LLM app boots fine. It answers the first few messages fine. Then, ten minutes into a real conversation, it quietly walks from the &lt;code&gt;support&lt;/code&gt; step into the &lt;code&gt;billing&lt;/code&gt; step that doesn't exist anymore, or loops &lt;code&gt;faq → support → faq → support&lt;/code&gt; until something times out. Nothing crashed at startup. The mistake was in the workflow's &lt;em&gt;shape&lt;/em&gt; the whole time — you just didn't find out until runtime, in production, with a user watching.&lt;/p&gt;

&lt;p&gt;This is the same class of bug as a malformed config file: the error exists the moment you write it, but the tool waits until the worst possible time to tell you. The fix is the same too. Catch it at load time, before a single step executes.&lt;/p&gt;

&lt;p&gt;In this tutorial we'll build a small, declarative LLM workflow as YAML, then validate its &lt;strong&gt;topology&lt;/strong&gt; — the graph of which step can hand off to which — before we run anything. We'll catch dead ends, transitions to steps that don't exist, and loops that can never terminate. All the code here runs against a real open-source package, &lt;a href="https://pypi.org/project/llm-workflow-router/" rel="noopener noreferrer"&gt;&lt;code&gt;llm-workflow-router&lt;/code&gt;&lt;/a&gt;, so you can follow along end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why runtime is the wrong place to find structural errors
&lt;/h2&gt;

&lt;p&gt;Most guardrails for LLM systems limit &lt;em&gt;volume&lt;/em&gt;: a max number of tool calls, a timeout, a retry cap. Those are useful, but they treat the symptom. A runaway loop gets cut off after N iterations — it doesn't get prevented, and you learn nothing about &lt;em&gt;why&lt;/em&gt; the workflow could loop in the first place.&lt;/p&gt;

&lt;p&gt;Structural validation asks a different question: &lt;strong&gt;before this workflow ever handles a request, is its graph even valid?&lt;/strong&gt; A step that transitions to a nonexistent target is invalid no matter what any model says. A step with no way out is invalid. A cycle through a step that isn't allowed to be re-entered is invalid. None of that depends on content, so none of it needs to wait for runtime to be caught.&lt;/p&gt;

&lt;p&gt;The mental model: your workflow is a directed graph. Nodes are steps ("containers"). Edges are the transitions each step permits. Validating the workflow means analyzing that graph and refusing to start if it's broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow as YAML
&lt;/h2&gt;

&lt;p&gt;Here's a minimal support flow. A user enters at &lt;code&gt;support_entry&lt;/code&gt;, which may hand off to &lt;code&gt;faq&lt;/code&gt; or refuse; &lt;code&gt;faq&lt;/code&gt; may only refuse. &lt;code&gt;REFUSE&lt;/code&gt; is a terminal state, not a step.&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="c1"&gt;# good_config.yaml&lt;/span&gt;
&lt;span class="na"&gt;entrypoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;support_entry&lt;/span&gt;

&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;support_entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allow_transitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;faq&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REFUSE&lt;/span&gt;
    &lt;span class="na"&gt;allow_reentry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;max_invocations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

  &lt;span class="na"&gt;faq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allow_transitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REFUSE&lt;/span&gt;
    &lt;span class="na"&gt;allow_reentry&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;max_invocations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are being declared per step, and each one is a rule the validator can check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;allow_transitions&lt;/code&gt; — the &lt;em&gt;only&lt;/em&gt; steps this one may hand off to. Anything else is an invalid transition, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;allow_reentry&lt;/code&gt; — whether the step may be entered more than once. This is what turns "is there a cycle?" into "is there a cycle that's actually a problem?"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_invocations&lt;/code&gt; — a hard ceiling on how many times the step may run. Must be greater than zero, or the step can never execute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;entrypoints&lt;/code&gt; names the authoritative roots of the graph. Declaring them explicitly means the validator doesn't have to &lt;em&gt;guess&lt;/em&gt; where the workflow starts — and it can flag any step that no path can reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating from the command line
&lt;/h2&gt;

&lt;p&gt;Install the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;llm-workflow-router
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then validate the config before you wire it into anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; router.cli validate good_config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"ok"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean exit, exit code &lt;code&gt;0&lt;/code&gt;. Now let's break it on purpose — the kind of breakage that's easy to introduce and impossible to spot by eye once a config gets large:&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="c1"&gt;# broken_config.yaml&lt;/span&gt;
&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;support_entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allow_transitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;faq&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;billing&lt;/span&gt;        &lt;span class="c1"&gt;# typo: this step does not exist&lt;/span&gt;
    &lt;span class="na"&gt;allow_reentry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;max_invocations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

  &lt;span class="na"&gt;faq&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allow_transitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;support_entry&lt;/span&gt;   &lt;span class="c1"&gt;# loops back into a no-reentry step&lt;/span&gt;
    &lt;span class="na"&gt;allow_reentry&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;max_invocations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

  &lt;span class="na"&gt;orphaned_step&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allow_transitions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REFUSE&lt;/span&gt;
    &lt;span class="na"&gt;allow_reentry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;max_invocations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three separate problems hiding in there. Run &lt;code&gt;validate&lt;/code&gt; and it stops you at the first hard error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; router.cli validate broken_config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"ok"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONFIG_VALIDATION_ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Container 'support_entry' has invalid transition target 'billing'. Must be a container name or one of {'REFUSE', 'PROCEED', 'PAUSE'}."&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;Exit code &lt;code&gt;2&lt;/code&gt;. Notice what the error message actually does: it names the offending step (&lt;code&gt;support_entry&lt;/code&gt;), the bad value (&lt;code&gt;billing&lt;/code&gt;), and the &lt;em&gt;set of things that would have been valid&lt;/em&gt;. That last part is the difference between an error that stops you and an error that helps you. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing every problem at once
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;validate&lt;/code&gt; is pass/fail — good for CI, where you want a nonzero exit and the first blocking reason. But when you're actually fixing a workflow, you want the whole picture, not one error at a time. That's what &lt;code&gt;analyze&lt;/code&gt; is for: it inspects the topology and reports everything, graded by severity, without raising.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; router.cli analyze broken_config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"container_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entry_containers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"orphaned_step"&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;"entrypoints_declared"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cycle_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cycles"&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="s2"&gt;"faq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support_entry"&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;"issues"&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;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CYCLE_WITH_REENTRY_BLOCKED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cycle ['faq', 'support_entry'] includes containers with allow_reentry=false: ['support_entry']"&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;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UNKNOWN_TARGET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Container 'support_entry' transitions to unknown 'billing'."&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;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WARNING"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"UNREACHABLE_CONTAINERS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Unreachable containers: ['faq', 'support_entry']"&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;This one report catches all three bugs I planted:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UNKNOWN_TARGET&lt;/code&gt;&lt;/strong&gt; — the &lt;code&gt;billing&lt;/code&gt; typo. A transition to a step that isn't in the graph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CYCLE_WITH_REENTRY_BLOCKED&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;faq&lt;/code&gt; transitions back to &lt;code&gt;support_entry&lt;/code&gt;, forming a loop &lt;code&gt;faq → support_entry → faq&lt;/code&gt;, but &lt;code&gt;support_entry&lt;/code&gt; declared &lt;code&gt;allow_reentry: false&lt;/code&gt;. The workflow contradicts itself: the topology requires re-entering a step the config forbids re-entering.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;entrypoint inference&lt;/strong&gt; falling apart. Because I removed the &lt;code&gt;entrypoints&lt;/code&gt; block, the analyzer had to &lt;em&gt;infer&lt;/em&gt; the root from graph shape — and the only step nothing points to is &lt;code&gt;orphaned_step&lt;/code&gt;, so it wrongly became the "entry." Everything else is now unreachable from that inferred root, hence the &lt;code&gt;UNREACHABLE_CONTAINERS&lt;/code&gt; warning. This is a great argument for always declaring &lt;code&gt;entrypoints&lt;/code&gt; explicitly: it removes the guessing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The severity split matters. An &lt;code&gt;ERROR&lt;/code&gt; is a contradiction that makes the workflow structurally invalid — it blocks. A &lt;code&gt;WARNING&lt;/code&gt; is a smell worth surfacing but not necessarily fatal. Cycles are a nice example of the distinction: a loop through steps that &lt;em&gt;all&lt;/em&gt; permit re-entry is a &lt;code&gt;WARNING&lt;/code&gt; (it can legitimately terminate via &lt;code&gt;max_invocations&lt;/code&gt;), while a loop through a step that forbids re-entry is an &lt;code&gt;ERROR&lt;/code&gt; (it can't).&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it in Python
&lt;/h2&gt;

&lt;p&gt;The CLI is a thin wrapper. If you're building the workflow programmatically — or want validation inside your own app's startup path — the same checks are one function call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;router.engine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WorkflowEngine&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;router.models.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContainerConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WorkflowConfig&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;router.models.enums&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InteractionState&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;router.models.metadata&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InteractionMetadata&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;router.validation.config_validator&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;validate_workflow_config&lt;/span&gt;

&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WorkflowConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;containers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support_entry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ContainerConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;allow_transitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REFUSE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;allow_reentry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;max_invocations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ContainerConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;allow_transitions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REFUSE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;allow_reentry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;max_invocations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;entrypoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support_entry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;validate_workflow_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# raises ConfigValidationError if the graph is invalid
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Config valid ✓&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;validate_workflow_config&lt;/code&gt; raises &lt;code&gt;ConfigValidationError&lt;/code&gt; on any hard error, so the natural place to call it is at load time — the workflow simply refuses to start if its shape is wrong. That's the whole idea: an invalid state is made unrepresentable at runtime because it was rejected before runtime began.&lt;/p&gt;

&lt;p&gt;Once the config is validated, evaluating a step is deterministic — the same metadata always yields the same decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WorkflowEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InteractionMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support_entry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;previous_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InteractionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCEED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;transition_history&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="n"&gt;invocation_depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support_entry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;requested_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;faq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EvaluationResult(state=&amp;lt;InteractionState.PROCEED: 'PROCEED'&amp;gt;, container='support_entry', reason=None, trace_id='demo-001')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine checks the request against the &lt;em&gt;validated&lt;/em&gt; rules: is &lt;code&gt;support_entry&lt;/code&gt; a real step? Under its invocation ceiling? Is &lt;code&gt;faq&lt;/code&gt; in its &lt;code&gt;allow_transitions&lt;/code&gt;? All yes, so the result is &lt;code&gt;PROCEED&lt;/code&gt;. Ask for a transition that isn't allowed and you'd get a &lt;code&gt;REFUSE&lt;/code&gt; with a machine-readable &lt;code&gt;reason&lt;/code&gt; like &lt;code&gt;INVALID_TRANSITION&lt;/code&gt; — no exceptions thrown mid-flight, just an explicit, inspectable decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth stealing: error messages that point at the fix
&lt;/h2&gt;

&lt;p&gt;Go back and look at that first failure message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Container 'support_entry' has invalid transition target 'billing'. Must be a container name or one of {'REFUSE', 'PROCEED', 'PAUSE'}.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It would have been &lt;em&gt;easier&lt;/em&gt; to write &lt;code&gt;ValueError: invalid transition&lt;/code&gt;. It would also have been useless. A good validation error answers three questions without making the reader go digging: &lt;strong&gt;where&lt;/strong&gt; is the problem (&lt;code&gt;support_entry&lt;/code&gt;), &lt;strong&gt;what&lt;/strong&gt; is wrong (&lt;code&gt;billing&lt;/code&gt; isn't a valid target), and &lt;strong&gt;what would be right&lt;/strong&gt; (a real step name, or a terminal state). The &lt;code&gt;analyze&lt;/code&gt; codes do the same job for tooling — &lt;code&gt;UNKNOWN_TARGET&lt;/code&gt;, &lt;code&gt;CYCLE_WITH_REENTRY_BLOCKED&lt;/code&gt;, &lt;code&gt;ORPHAN_CONTAINER&lt;/code&gt; are greppable, stable identifiers you can build dashboards or CI rules around.&lt;/p&gt;

&lt;p&gt;If you take one habit away from this, let it be that one. Validation that only says &lt;em&gt;no&lt;/em&gt; makes people resent your tool. Validation that says &lt;em&gt;no, here, because of this, and here's what valid looks like&lt;/em&gt; makes people trust it. The effort is a few extra f-strings; the payoff is every future debugging session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Structural bugs in an LLM workflow — dead ends, phantom transitions, non-terminating loops — are present the instant the config is written. There's no reason to let them surface at runtime. By modeling the workflow as an explicit graph and validating its topology at load time, you convert a whole category of production incidents into a fast, boring failure at startup with a message that tells you exactly what to fix.&lt;/p&gt;

&lt;p&gt;Everything here runs against the open-source &lt;a href="https://pypi.org/project/llm-workflow-router/" rel="noopener noreferrer"&gt;&lt;code&gt;llm-workflow-router&lt;/code&gt;&lt;/a&gt; (MIT). Try &lt;code&gt;validate&lt;/code&gt; and &lt;code&gt;analyze&lt;/code&gt; on your own configs — and if you're building anything where an AI system hands off between steps, put the validation call in your startup path before you need it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
