The Upgrade Path Is a Product Decision

Shibin Das avatar
Shibin Das

Atelier CMS makes one promise that shapes almost every other decision in it:

Any version upgrades to the latest. Fully automated. No hiccups.

Not “supported upgrade path.” Not “see UPGRADING.md.” The operator pulls a new image, the appliance converges itself, and their site keeps working — whether they last upgraded last week or six releases ago.

That sentence reads like an ops feature. It isn’t. It’s a veto, and it sits on the part of the process where the fun happens: choosing what goes in.

Because the thing about an automated upgrade is that it has to work on a machine I have never seen, whose database I cannot inspect, running a version I shipped months ago and have half-forgotten. Every dependency in the product is a dependency in that unattended migration. Every module we enable is a module some future release will have to uninstall for someone, remotely, unsupervised, on the first try.

We just finished a refactor that was mostly about honouring that. Here is what it actually cost.

But first, why the rule exists at all — because it isn’t a taste thing.


Who Is On The Other End

Almost every upgrade convention in the Drupal world quietly assumes a competent hand at the other end. composer update, then drush updatedb, then read the deprecation notices, then fix the two contrib modules that broke, then export config. It’s a fine assumption when the other end is an agency with a staging environment and a retainer.

Atelier is not built for that end. It’s built for the person who wants a website, has no agency, has never heard of Drupal, and does not care that there’s a Drupal in there at all. They aren’t going to open a terminal. They shouldn’t have to know what a terminal is.

Work forward from that and the whole thing follows:

They cannot run a command. So there is no upgrade procedure — there’s a new version, and it applies itself. Anything that would have been a step in a document has to become code that runs unattended.

They cannot diagnose a failure. This is the part people underestimate. It isn’t that debugging is inconvenient for them — it’s that the failure is unreadable. A stack trace, a config-import conflict, a module stuck half-uninstalled: to a non-technical operator these are all the same event, and that event is “my website is broken and I don’t know why.” There is no partial recovery, no “just run this one command.” There’s a support request, or there’s abandonment.

They cannot judge risk in advance. An agency reads a release note and decides whether to take it this sprint. Our operator sees “update available.” So we don’t get to offload the decision. If it’s offered, it’s safe — that has to be true, because it’s the only thing they can act on.

Nobody is holding a backup they know how to use. The appliance takes snapshots, but “restore from a snapshot” is our job to make one action, not a runbook we hand over.

Put together, the standard escape hatch is gone. Most software’s upgrade story is really “upgrade, and if it goes wrong, someone technical will sort it out.” Strip out that clause and the remaining sentence has to be true on its own.

So the bar isn’t “as good as Drupal upgrades usually are.” The bar is a phone app. Nobody reads a changelog before their maps app updates, nobody keeps a rollback plan, and nobody has ever had to uninstall a leftover dependency by hand afterwards. That’s not a low bar because it’s easy — it’s a low bar because ordinary people have been trained by every other piece of software they own to expect it, and a CMS that fails it feels broken even when it’s behaving exactly as documented.

That is where the veto comes from. It isn’t discipline for its own sake. It’s that the only alternative to getting this right is asking someone who cannot possibly do it to do it anyway.


Removing a Module Takes Two Releases

Start with the smallest lesson, because it’s the one that generalises.

We had six provider modules to delete. Dead surface — nothing called them any more. So the cleanup did the obvious thing: dropped them from the enabled-modules config and removed the packages, in one release.

That works perfectly on my machine. It breaks every site that already exists.

Drupal needs a module’s code on disk to uninstall it. Ship both halves at once and the code is gone before the uninstall runs, so the module stays enabled in the database with nothing left to remove it. The site isn’t broken loudly. It’s broken in a way that only shows up two releases later, on somebody else’s install, as an unremovable ghost.

So the rule we earned:

Release N     drop it from core.extension + the install recipe
              KEEP the composer requirement (the code must still be there)

Release N+1   composer remove

One deletion, two releases, in that order, always. It’s written into the appliance README rather than a commit message, because the next person tidying composer.json will otherwise reintroduce the package and never know why it was pinned.

The interesting part isn’t the rule. It’s the shape of the mistake: the correct code, in the wrong number of releases. You cannot catch that class of bug by reviewing a diff. You catch it by asking, every time, “what does this do to a site that skipped this version?”


When Your Patch Queue Is Telling You Something

The bigger no was leaving drupal/ai — the shared AI abstraction layer — and building our inference layer directly on symfony/ai.

That looks like a not-invented-here decision from the outside. It wasn’t. The argument that actually decided it was our own patch directory.

We were carrying four Composer patches. Three were against the AI stack. And when I read them properly, the two that mattered were not bugs:

  • One fixed a tool payload emitting "parameters": null for a no-argument tool. OpenAI tolerates it. Put a proxy in front of Anthropic and the whole request dies. The payload was built in a base class every OpenAI-shaped provider inherits — so it was a conformance failure inside the conformance layer.
  • The other, seven hunks of it, existed because one provider executed tool calls internally instead of returning them. That’s not a broken function. That’s a different reading of what a chat provider is for — and fatal to us, because our workflow engine routes tools by design.

Underneath those, our own onboarding code had quietly grown nine hardcoded per-provider maps and five documented behavioural divergences between providers that were supposed to be interchangeable. Roughly fourteen workarounds. None of them individually unreasonable.

Here is the thing I’d have missed a year ago: that count is not a code-quality signal, it’s a structural one. The layer couldn’t enforce conformance because it didn’t own the plugins it abstracted over — different companies, different repos, no authority to say no. A layer that can’t reject a non-conforming implementation isn’t an abstraction; it’s a naming convention. And every workaround it needs is a silent-breakage candidate on an unattended upgrade.

The alternative wasn’t obviously safer, and I want to be honest about why it won: symfony/ai’s bridges live in one repository, one CI run, one shared contract layer. Co-location makes conformance enforceable — not guaranteed. We still verify every bridge we depend on. But “enforceable” is the difference between a dependency I can reason about and one I can only hope about.

Two things made it a decision rather than a gamble. First, staying wasn’t free either: the next major of the layer we were leaving narrowed its core requirement, restructured around five new plugin managers, and dropped seven submodules. So the real comparison was migration against migration, not migration against standing still. Second, we proved the migration on a live install before committing to it: a real tool-aware turn, resolved through our own reasoner, using the credential already stored on that site — no re-onboarding, because “the operator must re-enter their API keys” is exactly the hiccup the promise forbids.

The migration deleted 84 lines whose comments were entirely about working around invalid JSON Schema being emitted upstream. Both patches became unnecessary. Six unit tests now lock that behaviour down — not as tests of a feature, but as regressions for the reasons the migration happened.


Deleting Code You Were Right to Write

The second half of the refactor was the one that stung.

Our workflow engine is FlowDrop, a contrib project — and I should say plainly that I maintain it, the same as I maintain Atelier. So this is not a story about waiting on a stranger’s roadmap. We needed behaviour FlowDrop didn’t have, so we ran a fork inside Atelier: our own node types, our own patches, our own plugin overrides. It worked. It was good code. Some of it I still like.

A fork is also a promise you make to yourself to keep re-applying your cleverness to every future upstream release, forever, on behalf of people who will never know it exists. Under an automated-upgrade promise, a fork is a debt with a floating rate.

Owning both ends doesn’t cancel that debt; it just changes who you have to argue with. The fork was still a fork — patches in Atelier’s repo, re-applied against every FlowDrop release. What changes is the failure mode. It isn’t an unresponsive maintainer. It’s the opposite: quietly landing product-shaped hacks in a general-purpose platform because you have commit access and a deadline, and discovering two releases later that FlowDrop’s other users are carrying the shape of Atelier’s problems.

And the honest version was affordable for a reason I can’t turn into general advice, so I’ll just name it: Factorial sponsors FlowDrop’s development, and that sponsorship paid for roughly a month of work on FlowDrop itself, for the Atelier collaboration on the AI Engine. Upstreaming is cheaper over the life of a product and more expensive this month. Somebody has to fund that gap, and most of the time nobody does — which is exactly why so many forks outlive the reason they were created.

So instead of maintaining the fork, the asks went upstream — filed and reviewed as things the platform would want, not as things Atelier needed, which is a distinction you have to enforce on yourself when you’re standing on both ends of it. Over a handful of alphas on the 2.3 line, they landed. And each time one landed, we deleted Atelier’s version:

  • a node type we’d written for declining-and-synthesising in an agent loop → retired for the upstream node with identical ports
  • a whole class that patched a node type’s config on-site because upstream omitted a setting → deleted, along with the update hook keeping it alive
  • our confirmation-policy stance → replaced by FlowDrop’s own pass over its side-effecting node types

That last one is the good example of not saying yes to everything, in both directions. FlowDrop decides which node types need a human confirmation by splitting on whether an effect leaves the site — the right call for a general workflow engine. Atelier adopts the split with exactly one exception: a node that saves content is re-pinned to ask, because the axis that predicts regret isn’t whether the effect is external — it’s whether an operator can undo it without having noticed it happened. A node that can publish gets a prompt. That exception stayed in the product rather than becoming the platform default, and it’s one documented deviation instead of a fork. That’s the shape the two hats are supposed to have.

The refactor ended with the fork at zero and about 3,100 lines deleted. Nothing about the product got more capable that week. Every future upgrade got cheaper.

Which is the trade I keep having to re-explain, including to myself: the fork was the fast path, and the upstream work was the cheap one — even when the upstream is yours. They are not the same axis, and the promise only cares about the second.


The Failure You Cannot Review

One more, because it’s the best argument I have for why this is a product concern and not a tidiness concern.

Two decisions, each individually correct:

  1. Our image stamps every file at a fixed timestamp, so builds are byte-stable and an appliance doesn’t re-download ~75 MB of layers it already has.
  2. Drupal 11.4 moved routing and library YAML parsing into a cache bin that is deliberately kept out of the cache-clear registry, because it’s designed to survive one — it invalidates on file modification time instead.

Read those together. After an upgrade, the new release’s YAML is byte-different and timestamp-identical. Core’s only invalidation signal says “unchanged.” The site serves the previous release’s parse forever, and no cache clear can fix it.

Version 0.8.2 shipped in that state. A route existed on disk and not in the router, and a module linked to it on every console page — so an admin got an exception logged on every single page load of the backend. Found on live instances. Fixed by truncating one cache table.

The fix, in the upgrade routine rather than the build:

# before updatedb, so migrations + config import + router rebuild
# all read the YAML we actually shipped.
# advisory, never fatal: the bin is created lazily.
truncate_file_parsing_cache_if_present

We deliberately didn’t fix it by unfreezing the timestamps. That satisfies core’s assumption honestly, but it pays layer churn on every release to fix a once-per-upgrade problem — and it makes correctness depend on a build detail nobody re-derives when they next touch the Dockerfile.

The two halves now name each other in comments: the Dockerfile says don’t drop the truncate while these timestamps are frozen, the upgrade script says why the timestamps make it necessary. And the real fix is neither of those — it’s a test that takes a real released image, upgrades it, and asserts every route our modules declare still resolves. Guard the invariant, not the mechanism. The next cache that pins a stale parse fails the same test, and nobody has to be clever twice.

Notice what this bug was not. Not a bad dependency. Not a careless commit. Two good decisions in different repos, colliding in a place no code review looks — an upgrade from a version that no longer exists in the working tree.


So How Do You Actually Pick a Dependency?

The refactor changed my checklist. It used to be roughly: does it work, is it maintained, is the code decent.

Now, before anything enters the product, I want to know:

Can this thing’s contract be enforced? Not “is it well designed” — who has the authority to reject a non-conforming implementation? If the answer is nobody, expect to own the difference. Count the workarounds you already have. That number is the running cost, and it only goes up.

Can I see the roadmap? Not a marketing roadmap. Issue queues, milestones, a maintainer who says out loud “2.0 will drop these submodules.” A dependency with a visible future can be planned around; one without it can only be reacted to, and reacting is how you end up shipping a breaking change to somebody’s unattended install.

How responsive are the maintainers — to me, specifically? This is the metric I most underrated, and FlowDrop is the easy version of it rather than evidence for it: I maintain FlowDrop, so of course the asks landed. The real test was the AI layer, where I had no standing at all. Can you file a well-argued, platform-shaped ask and have it land in an alpha? That is the difference between deleting your fork and feeding it forever, and it’s worth testing early with a small ask, before you’re structurally committed. If you do own both sides, the question inverts: nobody else is there to reject the ask that’s good for your product and bad for the platform. You have to be that person, on purpose, against your own deadline.

And what does saying no cost today? Sometimes a lot. There are features we want, that our users would use next week, that we are not shipping because the module that provides them has a known incompatibility with something in our path. That’s not caution, it’s arithmetic: a feature is worth one release, and a broken upgrade is worth every release after it — and the person who pays for the broken one can’t fix it.


The uncomfortable summary is that an automated upgrade path is not something you add. It’s something you spend — on features you decline, forks you delete, patches you refuse to carry, conversations with maintainers who owe you nothing, and sponsored weeks that went into a dependency instead of into the product.

I think it’s the right thing to spend on, because of who’s on the other end. Somebody who wanted a website, got one, and has no idea there’s a CMS under it — let alone which one. Every release where nothing broke is a release where they never had to find out.

That’s the product. The features are just what it does in between.

Back the work behind the writing

These posts come out of building things in the open — Atelier, FlowDrop, and a long tail of Drupal contributions. Free to use, self-hosted, and yours to run. If any of it saved you time, you can help keep it going.

Sponsor monthly Buy me a coffee
What this funds
Shibin Das

Created by Shibin Das

Drupal Developer. Spice Dealer. Prying on Information Architecture nowadays.

Recommended for You

FlowDrop

Why One Big AI Skill Is Slower Than Many Small Ones

FlowDrop

Configurable Workflows Are the Future — Why I Built FlowDrop