<?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: Scott Mallinson</title>
    <description>The latest articles on DEV Community by Scott Mallinson (@scottmallinson).</description>
    <link>https://dev.to/scottmallinson</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%2F363795%2Fb2c6c78d-6421-4bfb-9e02-951768946c39.jpeg</url>
      <title>DEV Community: Scott Mallinson</title>
      <link>https://dev.to/scottmallinson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/scottmallinson"/>
    <language>en</language>
    <item>
      <title>From merged pull requests to a performance review</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Mon, 27 Jul 2026 10:36:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/from-merged-pull-requests-to-a-performance-review-1hj2</link>
      <guid>https://dev.to/scottmallinson/from-merged-pull-requests-to-a-performance-review-1hj2</guid>
      <description>&lt;p&gt;Every engineer I know is supposed to keep a work log, and almost none of them do. The intention is real. You start a running note, you fill it in diligently for about a week, and then a deadline lands and the note goes quiet. By the time the next performance review comes round you're staring at six months you can't account for, reconstructing your own year from commit history and half-remembered standups. The brag document everyone tells you to keep is empty, because keeping it is exactly the kind of low-urgency admin that loses every fight against actual work.&lt;/p&gt;

&lt;p&gt;The thing is, I'm already producing a detailed record of what I do. It's just not in a note. It's in my pull requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The raw material is already there
&lt;/h2&gt;

&lt;p&gt;A merged pull request is a surprisingly good primary source. It has a title, a description, a diff, the files it touched, a rough sense of scale, and a timestamp. String a week of them together and you've got a fairly honest account of where your effort went, written at the moment you did the work rather than reconstructed months later under duress. The problem was never a lack of data. It was that nobody wants to sit down on a Friday and turn a list of PRs into prose.&lt;/p&gt;

&lt;p&gt;So I stopped doing that part by hand. A scheduled job reads my merged pull requests, hands them to an LLM with a fixed prompt, and gets back a short daily note: a one-line summary, a tidied list of what each PR actually changed and why it mattered, and a few tags for the domain and services involved. That note gets written into an Obsidian vault. Daily notes roll up into a weekly summary, and the weekly notes are what I actually read when a review comes round.&lt;/p&gt;

&lt;p&gt;At work this runs through GitHub Copilot, inside my employer's approved tooling, so no code or PR data leaves the boundary it's meant to stay inside. The portable version I run at home does the same job through a self-hosted n8n workflow with a Claude doing the synthesis. The write-back into Obsidian is identical either way: the vault sits behind an MCP server fronting Obsidian's Local REST API plugin, so the model works through a small set of file operations (list, read, put, append, patch) rather than being handed a filesystem and trusted to behave. That constraint is doing more work than it looks like it is. A model that can only patch named regions of named files is a model that can't quietly reorganise your vault.&lt;/p&gt;

&lt;p&gt;The whole pipeline is less elaborate than the description makes it sound:&lt;/p&gt;

&lt;p&gt;See the original post at &lt;a href="https://scottmallinson.com/from-merged-pull-requests-to-a-performance-review/" rel="noopener noreferrer"&gt;https://scottmallinson.com/from-merged-pull-requests-to-a-performance-review/&lt;/a&gt; for the Mermaid diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt is the program
&lt;/h2&gt;

&lt;p&gt;There's barely any code here. Each stage is a long, boring markdown file of numbered steps that gets handed to a model, and those files are the actual artifact: versioned, edited, argued with. One runs the daily loop over new pull requests. One does the weekly synthesis. One keeps a knowledge base of repo and service notes current against the organisation's GitHub.&lt;/p&gt;

&lt;p&gt;The thing that surprised me is the ratio. The part of the daily prompt that says &lt;em&gt;what to write&lt;/em&gt; is a handful of lines: summarise the change, note the components affected, judge the complexity, tag the domain. Almost all of the rest is defensive: rules about what the job must not do, must not assume, and must not conclude. Writing the summary was never the hard part. Not corrupting a vault you've been accumulating for years is the hard part, and a model that will cheerfully invent a plausible answer rather than admit a query failed is exactly the wrong tool to point at it unsupervised.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that makes it usable
&lt;/h2&gt;

&lt;p&gt;Pull requests only capture the work that turned into code. The review you unblocked, the design you argued for in a meeting, the demo you gave to stakeholders, the afternoon you spent helping someone else land their change. None of that shows up in a diff, and it's often the work that matters most when somebody is deciding whether you've grown into a more senior role.&lt;/p&gt;

&lt;p&gt;So the generated sections are wrapped in marker comments, and anything I type outside those markers survives the next sync. The script only ever rewrites the region between its own start and end comments. I can drop a few bullets into a daily note about a meeting or a decision, and they sit there permanently while the PR-derived content underneath gets regenerated around them. The automation owns the parts it can see. I own the parts it can't.&lt;/p&gt;

&lt;p&gt;That split turned out to be the whole trick. An automation that overwrites your manual notes is worse than useless, because you quietly stop trusting it with anything you care about. One that treats your own additions as load-bearing and works around them is something you'll actually leave running for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules worth stealing
&lt;/h2&gt;

&lt;p&gt;If you build one of these, the defensive rules are the part to copy. Each of these earned its place by something going wrong first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a real idempotency key.&lt;/strong&gt; The PR URL is the key, and nothing gets appended to a daily note, a repo note or a career artifact until the vault has been searched for that exact URL. Re-runs then cost nothing and fix nothing twice. Without this you get a work log that quietly duplicates itself, which is a very slow way to discover you can't trust it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write to the merge date, not the run date.&lt;/strong&gt; A pull request merged on Tuesday belongs in Tuesday's note even if the job runs on Thursday. It sounds obvious written down, and the naive version does the opposite by default. Everything lands in today's note, because today is the date the model has closest to hand. The merge timestamp from the API is the only date that's allowed to decide which file gets written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resolve the cutoff from the notes, not from the calendar.&lt;/strong&gt; Each note carries its own sync metadata in frontmatter, including the last date the loop processed. Reading that back is what makes a same-day rerun safe. Take "today" as the cutoff just because today's note exists and you'll skip everything you merged before lunch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; This is the one I'd insist on. To a language model, a query that returned nothing and a query that failed look identical, and both invite the same tidy conclusion: no work today. So the prompt forbids writing "no new pull requests" unless the primary search &lt;em&gt;and&lt;/em&gt; an independent cross-check written against a different API both come back empty, plus a backstop that re-scans the whole current week regardless of the incremental cutoff, and unions the results. An automation that reports "nothing happened" on a day you shipped is worse than one that crashes, because a crash gets fixed and a false negative gets believed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate by re-reading.&lt;/strong&gt; The last step before reporting success reopens every note the run touched and confirms the PR URLs are actually in them, then searches the vault for each URL to check the knowledge base and career entries landed too. Models will report success on writes that never happened. Verification against the vault, not against the model's own account of itself, is the only thing that catches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave a run record.&lt;/strong&gt; Every run appends a short entry to a tracker note: the resolved identity it searched as, the cutoff it used, how many candidates each query returned, which notes it wrote, and any fallback path it took. When the output eventually looks wrong, that's what you debug from. Otherwise you're reverse-engineering the behaviour of a non-deterministic process from its output alone, which is as unpleasant as it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the weekly pass actually consolidates
&lt;/h2&gt;

&lt;p&gt;The weekly stage is where the pile of daily notes turns into something you'd willingly read, and its most important rule is a prohibition: it is not allowed to query GitHub at all. Daily notes are its only permitted input.&lt;/p&gt;

&lt;p&gt;That constraint matters more than it looks. If the weekly pass could re-query pull requests, you'd have two sources of truth immediately drifting apart, and the manual context you carefully typed into Tuesday's note would get outranked by a diff the model finds more legible. The weekly note would slide back into being a list of PRs, which is the thing the whole exercise exists to escape. Each layer trusts the layer below it and nothing reaches back past its own input.&lt;/p&gt;

&lt;p&gt;What consolidation actually means here is a transposition. The daily notes are organised by day; the weekly note is organised by &lt;em&gt;kind&lt;/em&gt; of work. It loads every daily note in the ISO week, pulls the required sections (the non-PR context and the signal scores) and redistributes everything into four buckets that run across the week rather than down it: key activities, decisions and thinking, collaboration, and operational work. A decision recorded on Tuesday and the follow-through that landed on Thursday become a single thread instead of two entries you'd have to join up yourself. That joining is the entire value. Anyone can concatenate five daily notes.&lt;/p&gt;

&lt;p&gt;Then it scores. Each day rates four dimensions (ownership, leadership, scope and execution) from nought to three, separately for PR and non-PR work, with the non-PR side weighted half again as heavy on the grounds that the work that doesn't show up in a diff is usually the work that's hardest to evidence later. The weekly pass sums those per dimension and bands the total: low, medium, or high. Crucially it then has to explain each band in prose, saying &lt;em&gt;why&lt;/em&gt; scope was medium and what would have made it high, which forces the number to be defended rather than just displayed.&lt;/p&gt;

&lt;p&gt;I treat the scores themselves with suspicion. They're useful as a nudge towards "this was a heavier week than it felt like at the time" and useless as anything resembling a measurement. A score of 10.5 labelled "medium" looks far more precise than it has any right to. What earns its keep is the sentence underneath it.&lt;/p&gt;

&lt;p&gt;The weekly prompt also names its own failure modes and tells the model to regenerate if it hits one. The note is invalid if it's PR-only, if it ignores the daily notes, or if it drops manual content. Those are three specific, recurring ways that summarisation goes wrong: reverting to the most machine-legible input, skipping the source entirely, and quietly discarding the half that's harder to compress. Naming them explicitly catches far more than a general instruction to be thorough. The single line that does the most work in the whole file is &lt;em&gt;synthesise, do not list&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking the vault questions
&lt;/h2&gt;

&lt;p&gt;The daily loop doesn't stop at the daily note. It also maintains a small set of career artifacts as it goes: a brag document that only accepts high-signal items, a rolling performance review draft, an architecture log tracking systems touched and patterns introduced, and an influence log grouped by domain. All of them use the same marker-block discipline, so they accrete over months instead of being rewritten each run.&lt;/p&gt;

&lt;p&gt;But the more interesting property is that none of this is a database. It's plain markdown with frontmatter and wikilinks, sitting in a folder. Which makes the whole vault a corpus you can point a model at and interrogate.&lt;/p&gt;

&lt;p&gt;That matters because real performance reviews aren't free-form. You're assessed against a competency framework: a ladder with named criteria and level descriptors, and some notion of what "meets" versus "exceeds" looks like at your grade. So the useful question isn't "what did I do this year". It's the framework itself, handed over verbatim: go through the vault, take each criterion in turn, find the evidence, cite it, and tell me where the evidence is thin.&lt;/p&gt;

&lt;p&gt;That's a very different task from summarisation, and the vault is shaped well for it. Criteria like architecture influence, cross-team collaboration or incident ownership map onto material that's already been extracted and tagged rather than buried in prose. The answer comes back per-criterion, with links down to the daily notes and, through them, to the pull requests underneath. And the gaps come back as gaps. An honest "no incident-ownership evidence recorded" is one of the more useful things it can tell you, because it's either a prompt to go and do that work or a prompt to start writing it down when you do.&lt;/p&gt;

&lt;p&gt;The signal scores give you a crude ranking layer on top of that. Because the same four dimensions are recorded all the way from daily up through weekly, you can sort periods, spot the dimension that's been persistently thin, and check whether the story you're planning to tell about your year actually matches the trend in your own notes. It's directional, not quantitative, and I'd never put a score in front of a manager. But it's a decent check on self-narrative, which tends to be built out of the three weeks you remember most vividly.&lt;/p&gt;

&lt;p&gt;Two caveats worth stating plainly. The vault only knows what you fed it, so a quarter you didn't write down looks exactly like a quarter you didn't work, and the model will happily paper over the difference if you let it. And the whole thing lives or dies on citation. A claim that traces back to a note that traces back to a merged PR is one you can defend in the room. A claim the model produced because it sounded like the right shape of achievement is one you should delete before anyone else reads it.&lt;/p&gt;

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

&lt;p&gt;The weakest part of all this is exactly the part that matters most: the work that never turned into code. When I haven't typed manual context into a daily note, the job falls back on inferring it from the diff, producing something like "probably coordinated this across a few services". Occasionally that's a fair reading. Just as often it's the model writing plausible fiction about meetings I never had.&lt;/p&gt;

&lt;p&gt;What makes that a good problem rather than a depressing one is that the fix isn't more clever prompting. It's more sources. Everything the diff can't see already exists, timestamped and structured, in systems I use all day: my calendar, the meetings themselves, my sent mail, the channels where the arguments actually happen. None of it needs inventing. It needs plumbing in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calendar is the obvious first source, and probably the highest value per unit of effort.&lt;/strong&gt; Outlook exposes it through the Microsoft Graph API, and a day's events are already a structured record of where the time went and who it went with, before anyone summarises anything. Just enumerating them turns "inferred from PR evidence" into something factual. The metadata carries more signal than it looks: attendee lists across team boundaries are a scope indicator you cannot derive from a repository; the organiser field distinguishes meetings you were summoned to from the design review you called, which is close to a direct leadership measurement; recurring events separate standing ceremony from the things you convened deliberately. A daily note that opens with the meetings that genuinely happened is a much better prompt for everything downstream than one that opens with a guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meetings themselves are the richer source, and the one that needs the most discipline.&lt;/strong&gt; Both Teams and Webex will hand over transcripts and generated summaries through their APIs: Graph for Teams, the Webex REST API for meetings and recordings. The temptation is to drop a transcript into the daily note and let the weekly pass deal with it. That's the wrong shape. A transcript is thousands of words of low-signal conversation wrapped around a handful of load-bearing sentences, and burying those sentences in bulk text just moves the extraction problem downstream to a stage with less context. Far better to ask a narrow question at ingest (what was decided, what did I commit to, what did I argue for and did it land) and write only the answer into the note. Narrow questions against a transcript are a task models are genuinely good at. Open-ended summarisation of one is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sent mail is underrated.&lt;/strong&gt; The instinct is to point at the inbox, but the inbox is a record of what other people wanted from you. The sent folder is a record of your own output: the thread where you made the case for an approach, the long reply that unblocked someone, the escalation you decided was worth making. Graph will filter it by date and recipient, and the filtering needs to be aggressive. The useful fraction is small, but it's the fraction with your reasoning in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack, or whichever channel tool you live in, is the noisiest and the most tempting.&lt;/strong&gt; Scoped to your own messages in public engineering channels, it catches something the other sources miss entirely: the design reasoning that gets written out properly in a thread and then never makes it into a document. Threads where you replied at length are review and unblocking work that leaves no other trace. Reply and reaction counts are a crude proxy for whether an argument actually landed. It's also the source most likely to manufacture significance from someone thinking out loud, so it's the one I'd add last and weight lowest.&lt;/p&gt;

&lt;p&gt;The architectural point that ties these together is that each of them should be a &lt;em&gt;source&lt;/em&gt;, not a &lt;em&gt;summariser&lt;/em&gt;. Every one lands as evidence in the daily note, inside its own marker block, tagged with where it came from. The fail-closed rule extends to all of them too, because a calendar API that errors must never be allowed to mean a day with no meetings. Which leads to the change I'm most interested in: a trust ladder. Something I typed myself outranks something pulled from a structured source like a calendar entry or a pull request, which outranks something extracted from a transcript, which outranks anything inferred from a diff. Right now every one of those lands in the same typeface, which is precisely what makes the inference problem corrosive: it launders a guess into the appearance of a fact. Record the provenance and the review query can prefer the top of the ladder, treat the bottom as a prompt to go and check, and stop pretending the difference doesn't exist.&lt;/p&gt;

&lt;p&gt;There's a boundary question that comes with all of this, and it deserves stating rather than discovering later. Transcripts and mail threads contain other people's words, and none of my colleagues signed up to appear in my work log. The rule I'd apply is the same one that already governs where this runs: stay inside approved tooling, extract my own contributions rather than archiving everyone else's, and keep the retained artifact at the level of "a decision was reached about X" rather than a verbatim record of who said what on the way there. That's not only the defensible position, it's the more useful one: the decision is the thing worth having in a review, and the argument that produced it usually isn't.&lt;/p&gt;

&lt;p&gt;The smaller items are tractable too. The retrieval flakiness, where queries come back empty on days I'd clearly shipped, traces back to how my identity resolves across accounts, and the honest fix is an explicit identity map resolved once at the start of a run and asserted against, rather than the cross-check currently papering over it. The rollup stops at weekly, and the monthly layer above it is a folder and an intention; that layer gets much easier to justify once daily notes carry meetings and decisions as well as merges, because then there's an arc to see rather than a longer list. And the citation trail that the whole review use case rests on is still only half automated: the links exist from career artifact down to daily note down to pull request, but assembling them into per-claim evidence means walking the chain by hand. Carrying the evidence identifiers along at generation time, rather than reconstructing them a year later, is the change that finishes the loop.&lt;/p&gt;

&lt;p&gt;Put together, that's a system where every claim in a review has a source, every source has a provenance, and the honest answer to a criterion I have no evidence for is that I have no evidence for it. That seems like a reasonable thing to build towards.&lt;/p&gt;

&lt;p&gt;None of this makes me a better engineer. It just means that when I'm asked what I did last quarter, the answer is sitting in a folder instead of somewhere in my head, and I didn't have to keep a work log to get it there.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>automation</category>
      <category>ai</category>
      <category>tooling</category>
    </item>
    <item>
      <title>On console warnings and the things we don't remove</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:40:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/on-console-warnings-and-the-things-we-dont-remove-d8j</link>
      <guid>https://dev.to/scottmallinson/on-console-warnings-and-the-things-we-dont-remove-d8j</guid>
      <description>&lt;p&gt;Not all worthwhile work shows up in a changelog a user would read. Removing unused tooling and quieting a noisy console are two of those jobs. Neither is user-visible. Both are worth doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Removing tooling nobody was using
&lt;/h2&gt;

&lt;p&gt;One service in the booking flow depended on a tool for generating API documentation from source comments. The pattern is familiar. Someone set it up, it worked, and at some point the docs stopped being generated or published. The tooling stayed.&lt;/p&gt;

&lt;p&gt;This kind of residue is common in long-running codebases. It isn't broken and doesn't throw errors. It just accumulates: in &lt;code&gt;package.json&lt;/code&gt;, in install time, in dependency-scan output, in the head of anyone who looks at the project setup and wonders what that config is for. Clearing it out took a small change. Drop the dependency, the config, and the npm scripts, then check nothing in CI was actually using it.&lt;/p&gt;

&lt;p&gt;Is it worth a review cycle? I think so. It sets a norm: we remove what we don't use instead of letting it pile up. The review catches the case where someone was relying on it but hadn't said so. And the commit history records why it went, which helps if anyone ever wants to bring it back on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defensive checks for prop types
&lt;/h2&gt;

&lt;p&gt;The more interesting change was in a fare search interface. Some components were receiving props that could be &lt;code&gt;undefined&lt;/code&gt; or &lt;code&gt;null&lt;/code&gt; in edge cases they weren't built to handle. Nothing was breaking and the app kept working, but React was logging warnings to the console about the unexpected prop values. The fix was to handle the missing-or-malformed cases explicitly before passing props in, rather than letting them through and hoping the component survived.&lt;/p&gt;

&lt;p&gt;Simple enough on the surface. The more interesting question is why console warnings pile up in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Console warnings as signal degradation
&lt;/h2&gt;

&lt;p&gt;A clean console is a working signal. When a new warning appears, developers notice it, look into it, and decide whether it's real. When the console is already full of warnings, that signal degrades. New warnings blend in, and the bar for "something is wrong" creeps upward. It happens gradually. One or two warnings in a rarely-hit edge case seem fine, then a few more turn up elsewhere, and before long people are filtering console output by habit while a genuine error sits unnoticed in the noise.&lt;/p&gt;

&lt;p&gt;So the real fix isn't "add null checks". It's treating the console as a meaningful output surface and keeping it clean on purpose: defensive prop handling so components don't get values they can't deal with, treating existing warnings as debt worth paying down, and refusing to merge new code that adds warnings. React's prop warnings are telling you something about &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership/" rel="noopener noreferrer"&gt;the contract between a parent component and its children&lt;/a&gt;, namely that the parent is sending something the child didn't expect. Ignore them and you're throwing away information about your component interfaces that might matter during a later refactor or upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The value of maintenance
&lt;/h2&gt;

&lt;p&gt;Neither change shipped a user-visible feature, and that's fine. A codebase that never gets this kind of attention fills up with clutter until it slows you down in measurable ways: noisy CI output, confusing structure, prop mismatches that graduate from warnings to real errors during an upgrade. Small maintenance compounds. So does neglect. You're choosing which.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>frontend</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Two kinds of correctness: currency bugs and ghost feature flags</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Mon, 13 Jul 2026 07:05:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/two-kinds-of-correctness-currency-bugs-and-ghost-feature-flags-2eic</link>
      <guid>https://dev.to/scottmallinson/two-kinds-of-correctness-currency-bugs-and-ghost-feature-flags-2eic</guid>
      <description>&lt;p&gt;Some bugs are loud and obvious. Others sit quietly in a codebase doing exactly what the code says, which isn't quite what anyone intended. The quiet ones are the more interesting category, because finding them is closer to archaeology than debugging. A &lt;a href="https://scottmallinson.com/the-notification-that-wouldnt-leave/" rel="noopener noreferrer"&gt;UI banner that wouldn't clear&lt;/a&gt; is the same shape of bug on the front end. These are its data-side cousins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The currency display bug
&lt;/h2&gt;

&lt;p&gt;In travel booking systems, taxes are complicated. There are base fares, carrier-imposed fees, and government taxes, each with its own rules about which currency it should display in. Domestic US travel adds specific airport facility charges and segment taxes with their own currency handling.&lt;/p&gt;

&lt;p&gt;The bug was that these taxes showed up with the wrong currency symbol. The amounts were right and the calculation was fine. What wasn't being carried through to the display layer was the currency context. In the exchange flow, where an agent is repricing a ticket change and quoting the tax breakdown to a customer, that's exactly the kind of error that erodes confidence in the tool. The agent can't be sure which values to trust.&lt;/p&gt;

&lt;p&gt;Fixing it meant tracing how those tax line items were assembled for rendering and making sure the right currency followed them through the pipeline. The root cause was a missing currency association at the point where the values were gathered into the display model. The fix itself was small once I found it. Tracing it was the bulk of the work, as usual.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature flag that didn't exist
&lt;/h2&gt;

&lt;p&gt;The subtler one corrected a feature flag reference in the pricing details service: &lt;a href="https://scottmallinson.com/the-quiet-work-of-removing-feature-flags/" rel="noopener noreferrer"&gt;a flag name that had been sitting in the code but had never actually been created in the feature flagging service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Every time the code evaluated that flag, it got a "not found" response, which the SDK treated as "off". Whatever the flag was meant to enable was never reachable. It wasn't failing loudly or throwing errors. It was silently defaulting to disabled, no matter what anyone had configured.&lt;/p&gt;

&lt;p&gt;Flag names are strings. Nothing checks a flag name at compile time against what's registered in your flagging service. Create a flag under a slightly different name than the one in the code, and the mismatch is invisible, at build time and at runtime, unless you go looking for it. Removing flags that have outlived their purpose is its own discipline. This is the opposite failure: a flag that was never really there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The passenger type that was never resolved
&lt;/h2&gt;

&lt;p&gt;A third bug of the same quiet kind. The exchange fare search service generates fare options when a traveller changes a booked ticket, and that calculation depends on the passenger type (adult, child, infant, various discount categories) because each attracts different fares and tax treatment.&lt;/p&gt;

&lt;p&gt;The fare generation helper was receiving a passenger type code, a short string like &lt;code&gt;ADT&lt;/code&gt; or &lt;code&gt;CHD&lt;/code&gt;, but it wasn't performing the lookup that &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership/" rel="noopener noreferrer"&gt;translates that code into the full passenger type definition from the reference data service&lt;/a&gt;. For standard adult passengers it probably held up, because the code was making assumptions that happened to be true for the common case. For less common categories, the missing lookup produced wrong results without failing loudly. The fix was to resolve every code through the reference data before fare generation runs.&lt;/p&gt;

&lt;p&gt;This is the kind of bug that's easy to wave through in review. The code looks plausible, the variable names suggest the right thing is happening, and the tests probably only cover the common cases. The tell is usually spotting that a code path receives something that looks like an identifier and treats it as if it were the thing itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config drift and why it stays hidden
&lt;/h2&gt;

&lt;p&gt;The flag and the passenger type are the same broader problem: drift. The code and the thing it references, a flag registry or a reference dataset, fall out of sync, and because neither side fails in a detectable way, the drift sticks around. The only symptom is behaviour that's quietly wrong, which is easy to blame on something else or miss entirely.&lt;/p&gt;

&lt;p&gt;You usually find these by poking around a nearby area and noticing the mismatch. Validating that references resolve at startup is one mitigation. A naming convention and a clear "create it before you reference it" habit is usually more practical. The loud bugs get fixed because they announce themselves. The quiet ones only get fixed if someone goes looking.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>debugging</category>
      <category>featureflags</category>
    </item>
    <item>
      <title>The shape of shared libraries</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Mon, 06 Jul 2026 09:03:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/the-shape-of-shared-libraries-409e</link>
      <guid>https://dev.to/scottmallinson/the-shape-of-shared-libraries-409e</guid>
      <description>&lt;p&gt;A shared library is defined less by what it does than by the shape it presents to everything that depends on it: its public surface, the contracts it implies, and the cost of changing either. Two bits of work from recently make that concrete. One was an export that was never declared. The other was a small change that quietly broke a numeric assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making implicit exports explicit
&lt;/h2&gt;

&lt;p&gt;A couple of icon components from a shared plugin library were being used by a downstream service without being part of the library's declared public surface. The consumer reached in by path, something like &lt;code&gt;import X from 'library/internal/path'&lt;/code&gt; rather than &lt;code&gt;import X from 'library'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That works, right up until it doesn't. Importing by internal path creates a hidden dependency on an implementation detail. Reorganise the library, move the internal path, and the consumer breaks. The library's authors have no way of knowing anyone relied on that path, because nothing declared the relationship.&lt;/p&gt;

&lt;p&gt;The fix was to add the components to the library's declared exports. It's close to a one-line change, but it earns its keep: it makes the dependency visible to the maintainers, it brings the components under the same deprecation and versioning treatment as the rest of the public API, and it lets static analysis trace the import graph correctly. &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership/" rel="noopener noreferrer"&gt;Libraries with undeclared consumers tend to become hard to refactor&lt;/a&gt;, because changes that look safe from the library's side turn out to break things elsewhere. Declaring the export brings the relationship into the open.&lt;/p&gt;

&lt;h2&gt;
  
  
  A breaking change disguised as a small one
&lt;/h2&gt;

&lt;p&gt;The shared Node logging library is built on Winston, which ships a default set of severity levels (&lt;code&gt;error&lt;/code&gt;, &lt;code&gt;warn&lt;/code&gt;, &lt;code&gt;info&lt;/code&gt;, and so on), each with a numeric priority where lower means more severe and &lt;code&gt;error&lt;/code&gt; is level 0. The gap was that nothing sits above &lt;code&gt;error&lt;/code&gt;. There's no way to say "this is worse than an error, this is on fire and needs a human now". &lt;code&gt;critical&lt;/code&gt; plays that role in syslog and most structured logging conventions, but Winston doesn't include it by default.&lt;/p&gt;

&lt;p&gt;Adding a custom &lt;code&gt;critical&lt;/code&gt; level at priority 0 and shifting the existing levels up by one fixes that. The change is tiny in lines of code and deceptively large in blast radius. Levels are usually referenced by name, like &lt;code&gt;logger.error(...)&lt;/code&gt;, which is fine. But any code comparing levels numerically ("only process events with level &amp;lt;= 1") is now pointing at a different level than before, because &lt;code&gt;error&lt;/code&gt; has moved from 0 to 1. Every numeric comparison that wasn't explicitly about &lt;code&gt;critical&lt;/code&gt; is suddenly off by one.&lt;/p&gt;

&lt;p&gt;That's why a change like this ships as a major version bump and asks consumers to update deliberately. The library exposes the new level and documents the shift; downstream services audit their numeric comparisons before upgrading. Writing the level was the easy part. The cost is rolling it through everything that depends on the library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape is the contract
&lt;/h2&gt;

&lt;p&gt;Both of these are the same lesson from different angles. A shared library's public surface is a contract whether or not you've written it down. An undeclared export honours that contract by accident. A numeric level shift changes it without telling anyone. Maintaining a library that lots of things depend on is mostly the work of keeping the contract explicit: declaring what's public, versioning what changes, and making the coordination visible instead of leaving consumers to find out when something breaks.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>architecture</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Three quiet bugs hiding in a cross-service feature</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Sun, 28 Jun 2026 08:05:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/three-quiet-bugs-hiding-in-a-cross-service-feature-48no</link>
      <guid>https://dev.to/scottmallinson/three-quiet-bugs-hiding-in-a-cross-service-feature-48no</guid>
      <description>&lt;p&gt;I shipped a feature that looked simple from the outside: you could save a flight quote from an AI-powered assistant into a separate trip planning view. The user-facing part is simple enough. Getting there meant coordinating changes across a fare search service, an AI assistant backend, and two independent frontend components, each with a slightly different idea of what a "saved quote" was.&lt;/p&gt;

&lt;p&gt;That kind of work is where a lot of the interesting coordination lives. It isn't algorithmically hard. It's demanding in a different way: you hold a complete picture of how the system behaves in your head while making changes in repositories that share no context with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent configuration bug
&lt;/h2&gt;

&lt;p&gt;Before any of the integration work could start, there was a bug to fix. AI assistant queries were returning fewer flight options than expected. No upsell options were coming back at all. Nothing in the system flagged it. Requests completed successfully and responses looked valid. You just got a narrower result set than you should have.&lt;/p&gt;

&lt;p&gt;Tracing the fare search payload for AI assistant requests, I found a field that capped the maximum number of upsell results at zero. Zero maximum upsells means return none. It had probably been set when the AI assistant integration was first wired up, and since upsell results aren't always prominent in early testing, nobody had caught it.&lt;/p&gt;

&lt;p&gt;The fix was a one-liner. Finding it was the work: tracing the full request chain to understand why AI assistant queries behaved differently from other consumers of the same API. That's how silent configuration drift goes. The value is technically valid, the system doesn't complain, and it quietly shapes what comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  When two components share an event
&lt;/h2&gt;

&lt;p&gt;The core integration challenge was making sure that when a quote was saved from the AI assistant, two separate frontend components updated correctly: the assistant itself, to show the saved status, and the trip planning view, to refresh its basket with the new quote.&lt;/p&gt;

&lt;p&gt;When I looked at how the trip planning component handled its refresh, it turned out to have its own local action for the job, a separate event doing the same thing as a shared action the AI assistant was already using. The duplication had grown gradually. The trip planning component came first, the AI assistant integration came later, and the shared event either didn't exist yet or wasn't visible when the local version was written.&lt;/p&gt;

&lt;p&gt;The fix was to drop the local duplicate and have the trip planning component respond to the shared event directly. Small change, but it compounds. Both components now respond to the same contract. If the event shape changes, it changes once. If you want to know what triggers a basket refresh, there's one place to look instead of two.&lt;/p&gt;

&lt;p&gt;I've written before about &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership/" rel="noopener noreferrer"&gt;what adding an AI layer taught me about type ownership&lt;/a&gt;, and this was the same principle wearing different clothes. Shared contracts go beyond type definitions. They're about the system having one authoritative source for each concept. Separate copies that start identical will drift apart eventually, and by the time they do, it's rarely obvious which one is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating HTML carefully
&lt;/h2&gt;

&lt;p&gt;One edge case in the rendering work. The saved-quote feature lets users copy a formatted version of a quote to the clipboard, with PDF export to follow. The copy content is rendered as HTML, so any string values drawn from API responses or user input need sanitising before they're embedded in the template.&lt;/p&gt;

&lt;p&gt;It's easy to miss. When you're building a formatter that turns structured data into an HTML string, interpolating values directly feels natural. But if any of those values come from external sources, even indirectly through several layers of typed objects, you've got an injection path. A quote description field containing a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag shouldn't end up executable in a clipboard payload.&lt;/p&gt;

&lt;p&gt;The fix was simple: escape HTML entities in any user-visible string before it goes into the template. Not complicated, but it doesn't surface in happy-path tests or feature demos. You have to think about it on purpose, or you find out about it later in a less pleasant way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What cross-service delivery actually involves
&lt;/h2&gt;

&lt;p&gt;Cross-service feature work is its own skill. The mechanics of any single change are usually straightforward: a field added here, a schema extended there, an event handler updated in a third repository. The hard part is keeping a clear model of how the pieces connect while you move between codebases that share no context.&lt;/p&gt;

&lt;p&gt;None of the things that had gone wrong here were individually complex. A suppressed search result, a duplicate event handler, an unsanitised string in a template. Each was a simple thing that had been allowed to exist because nobody had traced the full flow end to end. That tracing is most of what cross-service delivery actually is. You hold the whole picture, notice the gaps, and fix what you find before the feature ships with them baked in.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>ai</category>
      <category>microservices</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The scaffolding tax: getting a new service properly bootstrapped</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Wed, 24 Jun 2026 15:32:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/the-scaffolding-tax-getting-a-new-service-properly-bootstrapped-5d57</link>
      <guid>https://dev.to/scottmallinson/the-scaffolding-tax-getting-a-new-service-properly-bootstrapped-5d57</guid>
      <description>&lt;p&gt;A lot of engineering work doesn't count as "building features" but has to happen before you can build features at any speed. Scaffolding a new service is squarely that: getting it from its initial template state into something actually deployable and maintainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  From template to real service
&lt;/h2&gt;

&lt;p&gt;We use an internal template to bootstrap new services. It gives you a working skeleton with the right structure, dependencies, and config patterns already in place. The catch comes afterwards. Once you've created a service from the template, there's a round of housekeeping to strip out the template's own identity and replace it with the new service's: config references, package names, internal identifiers, anything still pointing at the template rather than the service.&lt;/p&gt;

&lt;p&gt;It's an hour's work and it matters. Leave a stale reference in the wrong place and you get subtle failures downstream. The wrong image gets pulled, metrics report under the wrong service name, alerts go nowhere because the routing rules don't recognise the identifier. Getting it right upfront is cheaper than tracking it down later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring into the deployment pipeline
&lt;/h2&gt;

&lt;p&gt;A service isn't real until it's in the deployment pipeline. We use GitOps-managed configuration to define what runs in each environment, so adding a service means updating those definitions: registering it, updating the exclusion lists that gate which services deploy where, and making sure the non-production infrastructure knows it exists.&lt;/p&gt;

&lt;p&gt;This looks like configuration editing, but it's closer to integration work. You're establishing the contracts between the service and the infrastructure that runs it. Get it in place and deployments become routine. Skip it and every deployment needs manual intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting dependency management right from the start
&lt;/h2&gt;

&lt;p&gt;We also set up the service's automated dependency updates properly from day one: finer-grained grouping aligned to upstream release cadences instead of one coarse batch, plus a first pass to clear the initial upgrade backlog before it builds up. The reasoning behind that grouping is worth a post of its own. The point here is that it's far cheaper to establish on a new service than to retrofit onto an old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why scaffolding quality compounds
&lt;/h2&gt;

&lt;p&gt;A service that's wired into the pipeline, has sensible dependency automation, and starts with clean configuration is one where future changes land quickly. One that's bootstrapped in a hurry, with stale references and manual deployment steps, picks up friction with every change. It's dull work. The alternative is treating it as someone else's problem to fix later, which usually means it never gets fixed at all.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shipping a conversational search flow across services</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Sat, 20 Jun 2026 17:43:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/shipping-a-conversational-search-flow-across-services-7eo</link>
      <guid>https://dev.to/scottmallinson/shipping-a-conversational-search-flow-across-services-7eo</guid>
      <description>&lt;p&gt;Some features are self-contained. Others cross enough systems that the changes have to land together, and the coordination becomes the hard part rather than any single change. Shipping an end-to-end conversational flight search was one of those. A user explores options through an AI assistant, selects one, and that selection saves into their booking basket. That single action spans three services and a frontend, and getting it working meant changing all of them roughly in parallel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the flight options flow
&lt;/h2&gt;

&lt;p&gt;The fare search service got improvements to how flight option data is structured in the conversational path. The previous structure had grown organically and was starting to show: handling and transforming it took more case-by-case logic than it should have. This pass cleaned up the data model, with consistent naming, clearer relationships between entities, and less special-casing at the edges, so downstream services and the frontend can work with it predictably.&lt;/p&gt;

&lt;p&gt;The trip quote service got a new &lt;code&gt;PATCH /quotes&lt;/code&gt; route for saving a selected option and merging it into an existing basket. It's a partial update. You're not replacing the basket, you're folding a selection into it, which is subtler than it sounds: the merge has to cope with a prior selection already existing, with new options conflicting with something already there, and with keeping the basket consistent throughout. &lt;a href="https://scottmallinson.com/designing-an-api-endpoint-for-an-ai-consumer/" rel="noopener noreferrer"&gt;What it accepts, what it returns, how it reports errors&lt;/a&gt; was most of the interesting work.&lt;/p&gt;

&lt;p&gt;The frontend got &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership/" rel="noopener noreferrer"&gt;updated type definitions to match&lt;/a&gt;. That sounds mechanical, but if the types don't reflect the actual shape of the data, you lose the compiler's ability to catch mismatches before they reach production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrumenting the MCP server
&lt;/h2&gt;

&lt;p&gt;A separate strand was adding observability to an MCP server, the component that sits between AI tooling and the backend services it calls, translating tool invocations into API calls and structuring the responses on the way back. The instrumentation covers APM tracing, metrics, and structured logging, so you can trace a tool call end to end: how long it took, whether it succeeded, which backend it hit, and where it failed.&lt;/p&gt;

&lt;p&gt;The constraint worth flagging is what you don't log. Tool-call requests can carry user-provided context and identifying information, so the instrumentation records the shape and outcome of each call — trace IDs, durations, status codes, error types — without persisting the content. That boundary is a design problem in its own right, and one I've written about separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context injection for AI coding assistants
&lt;/h2&gt;

&lt;p&gt;A different thread again: a set of hooks for our GitHub Copilot configuration that inject context at different points in a development workflow, things like analytics context, test state, feature-flag configuration, and workspace information. Assistants are most useful when they understand the context they're working in. Without it they give generic answers that are technically correct and no use in your actual codebase.&lt;/p&gt;

&lt;p&gt;The catch is that injecting too much backfires. Larger context costs tokens, and past a certain point the assistant spends its attention on the context instead of the problem. So the hooks are built around specificity. Each one fires at the moment its context is relevant: pre-chat hooks set up the initial picture, pre-tool-use hooks add context for the operation about to happen, post-tool-use hooks handle the follow-up. Getting it right is empirical. You find where the assistant gives unhelpful answers, work out what context would have helped, and add a hook there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming as a form of maintenance
&lt;/h2&gt;

&lt;p&gt;The feature had picked up two naming conventions as it evolved, one term in some places and another elsewhere. Neither was wrong, but having both meant reading the code required a constant mental translation. A codebase-wide rename pulled everything onto one vocabulary: service names, endpoint paths, function names, test descriptions.&lt;/p&gt;

&lt;p&gt;It's the kind of change that's easy to defer, because it doesn't fix a bug or add a feature. The cost of deferring just compounds quietly. Every new developer has to learn the mapping, every review is a little harder, every search has to account for both terms. Doing it once, properly, is cheaper than living with the split.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>ai</category>
      <category>microservices</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Release pipelines should be boring</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Tue, 16 Jun 2026 13:26:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/release-pipelines-should-be-boring-2jl5</link>
      <guid>https://dev.to/scottmallinson/release-pipelines-should-be-boring-2jl5</guid>
      <description>&lt;p&gt;Release automation tends to get written in a hopeful mood. One pull request merges, one job runs, one tag gets created, everything lands cleanly. You assume only one thing happens at a time. The assumption isn't deliberate. It's just how you think when you're writing the happy path.&lt;/p&gt;

&lt;p&gt;The trouble is that the happy path is a special case. As soon as a repository has busy automated dependency updates, several pull requests can merge within minutes of each other, and each merge fires its own release job. The jobs start from roughly the same point and then race each other to write back. We had a shared GitHub Actions template running releases across a set of repositories, and it turned out to be hiding three separate race conditions, each at a different stage of the same job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two jobs creating the same tag
&lt;/h2&gt;

&lt;p&gt;The first race is at tag creation. Two jobs start at almost the same moment, both read the current version, both compute the next one, and both try to create the same version tag. One wins. The other fails with a tag conflict.&lt;/p&gt;

&lt;p&gt;The fix is a concurrency group on the workflow. GitHub Actions supports this natively: you name a group, and a run that would join it while another is in flight either waits or gets cancelled. For releases you want it to wait. You're serialising, not skipping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, two release jobs in the same repo queue instead of colliding. The second runs cleanly once the first is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two jobs pushing to the same branch
&lt;/h2&gt;

&lt;p&gt;Serialising tag creation doesn't cover the last step: pushing the version-bump commit back to the branch. A job checks out the repo, bumps the version, commits, and pushes. If another job pushed in the gap between checkout and push, the working copy is now a commit behind, and git correctly refuses the non-fast-forward push.&lt;/p&gt;

&lt;p&gt;There are two halves to fixing this. The first is to sync with the remote at the last possible moment before writing, so the bump lands on a current view of the branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sync with remote before versioning&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git pull --rebase origin ${{ github.ref_name }}&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bump version&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm version patch --no-git-tag-version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering matters. Pull and rebase before the bump, so you're rebasing onto current remote state rather than dragging your version commit over changes that might conflict with it.&lt;/p&gt;

&lt;p&gt;The second half is to make the push itself resilient. Even with a pre-push sync, two jobs can reach the push inside the same narrow window. So the push step retries: on a non-fast-forward rejection it pulls, rebases the bump onto the new tip, and tries again, bounded so it fails loudly instead of looping forever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Push Changes&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;for attempt in 1 2 3; do&lt;/span&gt;
      &lt;span class="s"&gt;if git push origin HEAD:${{ github.ref_name }}; then&lt;/span&gt;
        &lt;span class="s"&gt;break&lt;/span&gt;
      &lt;span class="s"&gt;fi&lt;/span&gt;
      &lt;span class="s"&gt;if [$attempt -lt 3]; then&lt;/span&gt;
        &lt;span class="s"&gt;echo "Push failed, pulling and retrying..."&lt;/span&gt;
        &lt;span class="s"&gt;git pull --rebase origin ${{ github.ref_name }}&lt;/span&gt;
      &lt;span class="s"&gt;else&lt;/span&gt;
        &lt;span class="s"&gt;echo "Push failed after $attempt attempts"&lt;/span&gt;
        &lt;span class="s"&gt;exit 1&lt;/span&gt;
      &lt;span class="s"&gt;fi&lt;/span&gt;
    &lt;span class="s"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that you recover after a failed attempt rather than pulling before every one. Failing first and then recovering avoids unnecessary work, and it avoids a window where a pre-emptive pull could rebase onto a conflicting state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up after a race that already happened
&lt;/h2&gt;

&lt;p&gt;By the time the fixes land, a race can already have left a mess. A job writes a version-bump commit locally but fails to push it, so the version recorded in the package manifest is a step ahead of the tags actually published. Recovering means going through the CI logs to find the run that partially completed, working out what it left behind, and replaying that specific bump cleanly against the current branch. The archaeology takes longer than the fix.&lt;/p&gt;

&lt;p&gt;That's the real cost of partial failures in automation. The failure itself is cheap. Reconstructing the state it leaves behind is what costs you. So you make release steps idempotent where you can: a step that's safe to re-run without doubling its effects turns a tense recovery into a re-run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a flaky pipeline is worse than it looks
&lt;/h2&gt;

&lt;p&gt;A release job that fails about half the time sits in an awkward blind spot. It's not bad enough to block anyone. You retry, it passes, you move on. The workaround is cheaper than the fix, so the failure gets normalised, and people stop reading it as a signal and start treating it as weather.&lt;/p&gt;

&lt;p&gt;The cumulative cost is real, and it isn't only the wasted retries. An unreliable pipeline changes how people work. If every release risks a babysitting session, the rational move is to batch changes up, which is the opposite of the small, frequent pull requests that make review easier and rollbacks cheaper. A pipeline that runs quietly on every merge takes that disincentive away, and goes back to being something nobody has to think about.&lt;/p&gt;

&lt;p&gt;Doing this in a shared template multiplies the payoff. The fixes land once, and every repository that inherits the template gets them without anyone rediscovering the problem repo by repo, one retry button at a time.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>devops</category>
      <category>automation</category>
    </item>
    <item>
      <title>Granular Dependabot groups and getting error attribution right</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Sat, 13 Jun 2026 20:58:37 +0000</pubDate>
      <link>https://dev.to/scottmallinson/granular-dependabot-groups-and-getting-error-attribution-right-3334</link>
      <guid>https://dev.to/scottmallinson/granular-dependabot-groups-and-getting-error-attribution-right-3334</guid>
      <description>&lt;p&gt;Dependabot has a default behaviour that doesn't scale well: one pull request per outdated dependency. For a repository with a hundred dependencies, a run generates dozens of individual PRs. Most are low-risk patch bumps a developer approves without reading closely — which means either they pile up unreviewed, or people start rubber-stamping them, which defeats the point of the review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why grouping matters
&lt;/h2&gt;

&lt;p&gt;Dependency grouping addresses this. Instead of one PR per package, you define groups — "all AWS SDK packages", "all testing libraries", "everything from this upstream source" — and Dependabot combines the relevant updates into a single PR. The result is far fewer PRs, each giving a complete picture of a set of related changes.&lt;/p&gt;

&lt;p&gt;The work was moving several repositories from ungrouped or coarsely grouped configs to ones with more granular, purposeful groups. The granularity matters: a group defined as "all dependencies" is barely better than no grouping — you still get one huge PR — while groups built around logical cohesion ("packages released together from the same upstream source") give you something you can actually review with confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying it across different stacks
&lt;/h2&gt;

&lt;p&gt;The interesting part of rolling this out is that each repo has a different dependency structure. A Node logging library has nothing in common with a .NET error-handling library or a React frontend. For the Node libraries, grouping around major upstream sources makes sense; for the .NET library, the groups follow NuGet namespaces; for the frontend, it's a mix of framework packages, tooling, and application libraries, each with their own natural groupings. You can copy the structure of the YAML across repos, but you still have to think about what actually belongs together in each — and that thinking is the part you can't automate away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error source attribution in a shared library
&lt;/h2&gt;

&lt;p&gt;The other change was about correctness in error reporting. A shared .NET library classifies errors and warnings across several services — capturing not just the message but its origin: which part of the system produced it. The origin is an enum, and one category was missing: a group of trip-related services whose errors weren't covered by any existing value. When those services produced an error, it either failed to classify or fell into a catch-all, making it harder to route the alert and slower to find the responsible team.&lt;/p&gt;

&lt;p&gt;Adding the value is straightforward. The more interesting question is why it was missing. &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership/" rel="noopener noreferrer"&gt;This is a common pattern with shared classification types&lt;/a&gt; — the enum gets defined early, before all the consumers are known, and then isn't kept in sync as new services adopt the library. The fix doesn't just make reporting more accurate; it removes the ambiguity that wastes on-call time. "The origin is unknown" means more digging before anyone can act; "the origin is the trip services layer" means the right team gets paged immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform hardening as a steady-state activity
&lt;/h2&gt;

&lt;p&gt;This is worth naming for what it is: platform hardening. Not new features, not architecture changes, but the continuous work of making the infrastructure more reliable, maintainable, and legible to the people who depend on it. A Dependabot config sits in a file almost nobody reads until dependency updates go wrong; an error-source enum is invisible to end users. Both keep a system that dozens of engineers work in daily manageable over time. The return is long-tailed and largely invisible, which is exactly why it tends to get deprioritised — and doing it consistently, even when nothing more exciting is on the board, is how a platform stays manageable.&lt;/p&gt;

</description>
      <category>engineering</category>
      <category>maintenance</category>
    </item>
    <item>
      <title>What adding an AI layer taught me about type ownership</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Tue, 09 Jun 2026 20:12:33 +0000</pubDate>
      <link>https://dev.to/scottmallinson/what-adding-an-ai-layer-taught-me-about-type-ownership-3o40</link>
      <guid>https://dev.to/scottmallinson/what-adding-an-ai-layer-taught-me-about-type-ownership-3o40</guid>
      <description>&lt;p&gt;I've been working on an AI-powered trip planning assistant that sits on top of an existing set of booking microservices. The AI layer is genuinely interesting work — natural language input, iterative trip refinement, the whole thing. But the most valuable engineering work had nothing to do with the AI itself.&lt;/p&gt;

&lt;p&gt;It was about types. Specifically, who owns them and what happens when they drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with local schemas
&lt;/h2&gt;

&lt;p&gt;The AI assistant service had its own validation schema for incoming flight search requests. It wasn't wrong, exactly — it reflected the shape of requests the service expected at the time it was written. But the canonical definition of what a valid flight search request looks like lives in the fare search service, and over time, subtle differences had crept in.&lt;/p&gt;

&lt;p&gt;This is a fairly standard distributed systems problem. When two services independently define what they think the same thing looks like, they'll stay in sync right up until they don't. A field gets added somewhere. A constraint gets tightened. Someone updates one schema and not the other. Nothing breaks immediately — the tests pass, the service starts — but you've created a time bomb.&lt;/p&gt;

&lt;p&gt;The fix was straightforward: pull the shared type out of the fare search package and use that directly in the AI assistant, removing the local definition entirely. One source of truth. When the API evolves, every consumer stays aligned automatically.&lt;br&gt;
Simple to describe. Surprisingly easy to defer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI features make this urgent
&lt;/h2&gt;

&lt;p&gt;Here's the thing about building an AI layer on top of existing services: it doesn't introduce new complexity so much as it surfaces the existing ambiguity.&lt;/p&gt;

&lt;p&gt;A traditional integration between two services fails fast. Service A sends a request to Service B, B returns an error, A logs it, someone gets paged. The feedback loop is tight enough that schema drift tends to get caught reasonably early.&lt;/p&gt;

&lt;p&gt;An AI assistant is different. The user is expressing intent in natural language. The assistant is interpreting that intent, deciding what to query, constructing requests, handling the responses. There are more layers of abstraction between the user's words and the actual API call. When something goes wrong, it might manifest as a confusing or unhelpful response rather than a clear error — which means it can go unnoticed for longer.&lt;/p&gt;

&lt;p&gt;More importantly, the AI layer is making decisions about how to construct requests. If its understanding of what a valid request looks like is subtly out of sync with reality, those decisions will be subtly wrong. Not catastrophically wrong — just wrong enough to be annoying and hard to diagnose.&lt;/p&gt;

&lt;p&gt;This is why schema consolidation that might have felt like a nice-to-have became genuinely urgent once an AI layer was involved. The model compounds every ambiguity downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session state is harder than it looks
&lt;/h2&gt;

&lt;p&gt;The other significant change this week was enforcing a required session-tracking header throughout the conversation service. This one is less about types and more about correctness guarantees.&lt;/p&gt;

&lt;p&gt;The header was already being passed in some code paths. The problem was "some" — in a service where every request needs to carry session context to maintain coherent conversation state, optional isn't good enough. A user iterating on a trip in natural language needs the system to remember where they are in the conversation. If that header gets dropped mid-flow, the session context is gone and the experience breaks in a way that's confusing rather than obvious.&lt;/p&gt;

&lt;p&gt;Making it a validated requirement — something the service explicitly checks for and rejects requests without — is the kind of change that feels bureaucratic until the alternative happens in production.&lt;/p&gt;

&lt;p&gt;The lesson isn't "validate everything". It's more specific: identify the invariants your system actually depends on and make them impossible to violate rather than relying on every caller to get it right.&lt;/p&gt;




&lt;p&gt;If you're planning to add an AI layer on top of an existing set of services, the time to audit your type ownership is before you do it, not after.&lt;/p&gt;

&lt;p&gt;It's not that the AI makes the type problems worse, exactly. It's that it makes them more consequential and harder to spot. A messy schema definition that was fine when the integration was service-to-service becomes a genuine liability when a model is making decisions based on it.&lt;/p&gt;

&lt;p&gt;The boring foundational work — shared types, enforced invariants, single sources of truth — isn't glamorous. But it's what determines whether the interesting work on top of it actually holds up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>The notification that wouldn't leave</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Mon, 08 Jun 2026 07:45:00 +0000</pubDate>
      <link>https://dev.to/scottmallinson/the-notification-that-wouldnt-leave-4o86</link>
      <guid>https://dev.to/scottmallinson/the-notification-that-wouldnt-leave-4o86</guid>
      <description>&lt;p&gt;There's a category of bug that's almost invisible until you're the one staring at it — the kind where something just doesn't go away when it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;In a booking management interface used by travel agents, there's a notification banner that warns agents when they're looking at a booking retrieved from a queue. The intent is clear: flag that this record came from a queue, not from a direct lookup. Once the agent has retrieved the record and the context changes, the banner should disappear.&lt;/p&gt;

&lt;p&gt;It wasn't disappearing.&lt;/p&gt;

&lt;p&gt;After retrieving a booking from the queue and moving on, the warning banner stayed visible — persisting across view transitions, sitting silently in the terminal in a state that no longer applied. Nothing was broken in the functional sense. Fares could still be searched, bookings could still be modified. The banner was just wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these bugs are easy to ship
&lt;/h2&gt;

&lt;p&gt;A lingering notification doesn't fail a unit test. It doesn't throw an error or cause a traceable exception. It simply stays on screen past its welcome, and the only way to catch it is to manually walk through the interaction flow and notice that something feels off.&lt;/p&gt;

&lt;p&gt;Tests tend to verify that things happen — components render, events fire, state updates. They're less good at verifying that things &lt;em&gt;stop&lt;/em&gt; happening at the right moment. A banner appearing is testable. A banner &lt;em&gt;not&lt;/em&gt; appearing after a specific sequence of interactions requires a test that explicitly asserts absence after a lifecycle transition, which is the kind of test that often gets skipped because "it's just UI state".&lt;/p&gt;

&lt;p&gt;Except for agents using this tool all day, it's not just UI state. A notification that outlives its context creates a small but persistent cognitive load: is the queue state still relevant? Did something go wrong? Should I be worried? The interface is lying, quietly, in a way that erodes trust.&lt;/p&gt;

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

&lt;p&gt;The root cause was that the notification state wasn't being cleared on the correct lifecycle event. When the booking was retrieved from the queue, the flag that triggered the banner was set. When the user transitioned away from that context, the flag wasn't being reset — it just carried forward into the next view.&lt;/p&gt;

&lt;p&gt;The fix was to wire the dismissal to the right transition point: when the queue retrieval context is left behind, clear the notification state. Once that connection was made, the banner behaved correctly — appearing when relevant, disappearing when not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader pattern
&lt;/h2&gt;

&lt;p&gt;This is a recurring shape in UI work on long-lived, stateful applications. State accumulates. Things that were true in one context bleed into the next if you're not deliberate about cleanup. It's especially common in applications built incrementally over years, where the component that sets a flag and the component that should clear it aren't obviously connected, and the original author of each may not have anticipated how they'd interact.&lt;/p&gt;

&lt;p&gt;The fix for any individual instance is usually small. The harder problem is building the habit of thinking about state exit conditions as carefully as state entry conditions — asking not just "when should this appear?" but "when should this definitively stop appearing, and am I certain that's covered?"&lt;/p&gt;

&lt;p&gt;Most of the time the answer is yes, and nothing bad happens. Occasionally it isn't, and you end up with a warning banner haunting a terminal long after the thing it was warning about has resolved.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
    <item>
      <title>Designing an API endpoint for an AI consumer</title>
      <dc:creator>Scott Mallinson</dc:creator>
      <pubDate>Thu, 04 Jun 2026 14:40:29 +0000</pubDate>
      <link>https://dev.to/scottmallinson/designing-an-api-endpoint-for-an-ai-consumer-3g2e</link>
      <guid>https://dev.to/scottmallinson/designing-an-api-endpoint-for-an-ai-consumer-3g2e</guid>
      <description>&lt;p&gt;Most search APIs follow a familiar pattern: you send a query, you get back a structured list of results. Each result is a record — fields, values, maybe some nested objects. It's designed to be consumed by code that knows what it's looking for and knows how to render it.&lt;/p&gt;

&lt;p&gt;Recently we built a new search endpoint with a different consumer in mind: an AI assistant. The response it needs isn't a result set. It's something the assistant can reason about and articulate in natural language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the response shape matters
&lt;/h2&gt;

&lt;p&gt;When a UI component consumes a search API, it knows exactly which fields to read. The price goes in this column. The departure time goes there. Any ambiguity in the response structure is a bug to be fixed in the mapping layer.&lt;/p&gt;

&lt;p&gt;When an AI assistant consumes a search API, the situation is more interesting. The assistant has to understand the results, decide what's relevant to say about them, and express that in a coherent response to the user. If you hand it a dense structured result set, it can technically work with it — but you're asking it to do a lot of implicit inference about what the data means and how the fields relate to each other.&lt;/p&gt;

&lt;p&gt;A response designed for conversational consumption makes that easier. You surface the information the assistant actually needs, organised in a way that corresponds to how a human would think about it, rather than in the schema optimised for data storage or rendering. The distinction is between a response that answers "what are these records?" and one that answers "what should I know about these options?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it without duplicating the core logic
&lt;/h2&gt;

&lt;p&gt;The practical challenge is that the new endpoint still needs to run the same underlying search — the same pricing logic, the same availability checks, the same filtering. You don't want two separate implementations of that. What changes is the shape of the output, not the logic that produces it.&lt;/p&gt;

&lt;p&gt;The implementation threads a new output path through the existing search pipeline: the core logic runs as before, but there's a step at the end that transforms the results into the conversational response format before returning them. This &lt;a href="https://scottmallinson.com/what-adding-an-ai-layer-taught-me-about-type-ownership-2/" rel="noopener noreferrer"&gt;keeps the business logic in one place&lt;/a&gt; while allowing the presentation layer to vary by consumer type.&lt;/p&gt;

&lt;p&gt;It's a pattern that shows up a lot in API design — the same underlying operation exposed through different interfaces for different consumers. The tricky part is getting the boundaries right so that the shared logic stays coherent and the per-consumer transformation doesn't start leaking back into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversational APIs as a distinct design problem
&lt;/h2&gt;

&lt;p&gt;There's a broader point worth making here. As &lt;a href="https://scottmallinson.com/keeping-orchestration-in-your-code-not-your-prompt/" rel="noopener noreferrer"&gt;AI assistants become a first-class consumer of backend APIs&lt;/a&gt;, the assumptions baked into standard API design start to matter in different ways.&lt;/p&gt;

&lt;p&gt;Traditional API design treats the consumer as code: deterministic, explicit, able to handle any well-formed response. AI consumers are more like people: they interpret, infer, and sometimes get confused by responses that are technically complete but not easy to reason about. Designing for them means thinking about meaning and context, not just schema validity.&lt;/p&gt;

&lt;p&gt;That's a different skill than standard API design, and I suspect it's going to become more important as more systems start exposing AI-facing interfaces alongside their traditional ones.&lt;/p&gt;

</description>
      <category>engineering</category>
    </item>
  </channel>
</rss>
