Three quiet bugs hiding in a cross-service feature

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.

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.

The silent configuration bug

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.

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.

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.

When two components share an event

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.

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.

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.

I've written before about what adding an AI layer taught me about type ownership, 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.

Generating HTML carefully

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.

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 <script> tag shouldn't end up executable in a clipboard payload.

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.

What cross-service delivery actually involves

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.

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.