A Deploy Is an Overlap, Not an Instant

A Deploy Is an Overlap, Not an Instant

Why an operator's already-open tab is the hardest client your platform has — and what it costs to keep it working.

An operator opens a console screen, starts a support case, and types a note into a dialog. Two minutes later we deploy. Their next interaction behaves differently from the one before it — and nothing on our side reports an error.

Two bugs in our console prototype made that concrete within a day of each other. In the first, a tab opened just before a release asked for a client bundle the release had removed, and the screen died behind a spinner. In the second, a module renamed a URL it owned, and a link on another module's screen kept pointing at the old one — because that screen had been handed the path as a string.

They share a root cause. In both, something outside a module depended on a detail the module was free to change: a file name in one case, a URL in the other.

The mistaken assumption underneath both was simpler: we had treated a deploy as an instant replacement rather than a period of coexistence.

What Actually Overlaps

A release replaces the registry, the client bundles and the routes together. The console shell is a separate service on its own cadence. And any number of operator tabs are open, each holding resolved asset URLs fetched hours ago, a bundle built against an API that may since have changed, and a page URL that was valid when it was bookmarked.

Two things overlap, and they fail differently — which is the distinction the rest of this post turns on:

  • Asset availability. Can the already-loaded tab still fetch the files it resolved earlier?
  • Contract compatibility. When it does make its next request, does the server still understand it the same way?

Retaining old assets solves the first and says nothing about the second. Both need an answer.

assets it already resolved. Its next server call, though, meets whatever contract is current.

The Architectural Decision

Our console shell knows no module by name. A module declares its screens and management routes beside its own code; the shell signs the operator in, asks the backend what exists for that person, and loads it. Adding or renaming a screen is a backend change.

That boundary is what makes coexistence tractable at all — but it only holds across time if two further things are true: the files a tab already resolved stay reachable, and no caller outside a module holds a detail the module may change. Three guarantees enforce that, each with a mechanism and a test.

Guarantee One: the Assets a Tab Resolved Stay Reachable

Each module's bundle is published under a path derived from the content of its JavaScript and stylesheet together, and those files are never overwritten at that path. Content addressing gives us immutability: a given URL always returns the same bytes, so a tab that resolved it earlier cannot be handed something different later.

Verification is a separate, weaker-support concern, and worth stating precisely rather than overstating. Where the browser supports integrity metadata for module scripts, the registry supplies it and the browser rejects a file whose bytes don't match. Where it doesn't, the guarantee we actually have is immutability plus a never-overwrite publishing rule — not browser-side verification. Those are different promises, and conflating them is how a team ends up believing it has a tamper guarantee it hasn't implemented.

Immutability creates its own obligation: if a URL must always return the same bytes, that URL must keep returning something. So the previous release's bundles are retained, not deleted. When the build writes a new manifest it reads the one it replaces, keeps every file that manifest names, and removes only what is older — a retention window of exactly one prior release. CI pulls the live image's bundle folder before each build, so "previous" means the release actually running rather than whatever sat on the build agent. A test walks the prior manifest's file list and asserts each path still exists with its recorded digest, catching both a deleted file and a file changed underneath its own identifier.

The trade-off: a one-release window is a deliberate bound, not a promise of indefinite compatibility. A tab left open across two releases will eventually request something that is gone. For that case the module host refreshes the registry once and retries before showing a fallback, so the tab recovers itself — with a visible reload, which is the cost we accepted rather than eliminated.

Guarantee Two: the Server's Answer Is Correct, or an Honest Refusal

A retained bundle solves loading. It does not solve meaning. An older screen can load perfectly and then send a request the current API interprets differently, or render a response whose shape has moved.

So two compatibility contracts are declared separately, because they change for different reasons and at different rates:

ContractWhat it coversBumped when
PlatformRegistry shape, request conventions, SDK semantics — one value, asserted by the shellA breaking change to any of those. Additive registry fields never bump it.
ModuleOne module's own management routes — declared in that module's spec, asserted by the screen from the registry entry it loaded withA removed or renamed path or field, a retyped field, a changed meaning. An addition never bumps it.

Each request carries the version the client was built against. On a mismatch the server declines the request and names which contract is stale, rather than answering it under a different interpretation: a stale shell is told the console needs updating, and a stale screen causes the host to reload that one module. A client asserting no version is served the current API, so health checks and manual debugging still work. Nothing is versioned by URL — there is no parallel route tree to maintain, and no moment when the same path means two things.

The check itself is a dozen lines in the single path every management request already takes. The part that matters is what forces the version to move. A number engineers are expected to remember to increment is a number that will be wrong, so each module's surface is pinned in a snapshot. A contract test then fails on three conditions: the surface changed and the version didn't, the version changed and no snapshot was added, or the snapshot is stale. The engineer who renames a field learns it from the test output, not from an operator.

A separate guard covers the other end of the lifecycle. Version checks protect the operator at request time; they do nothing for an engineer writing a screen against a route that changed yesterday. For that, each screen's client is generated from the live API surface, so a screen reading a removed field fails to compile. Each catches a failure the other cannot see, which is why we kept both: generated clients guard the engineer and the build; version checks guard the already-open tab.

The trade-off: a refusal is still an interruption. An operator mid-case gets a reload instead of a wrong answer, which is the right order of preference — but it is not a free outcome, and it puts a real obligation on screens to preserve in-progress input across a remount.

Guarantee Three: Callers Name Intent, Not Routes

Now the second bug. One screen opened another module's screen by naming it — module code plus a hand-assembled path string. It worked the day it was written and coupled the caller to the exact spelling of a URL it didn't own. When the owner renamed that path, nothing in the caller's bundle changed, nothing failed at build time, and the link went nowhere.

Two changes fixed the class rather than the instance.

Within a module, pages have stable ids. A page declares an id and a path; the runtime matches the URL against declared paths and hands the module its page and params. Navigation is by id, never by assembling a string, and a lint refuses code that parses its own path.

Across modules, a caller declares an intent against an entity type. It says what it wants to do, to what — never which module owns it or where that module put it:

ts

// The caller names an action and an entity type. It never names a module or a path.
if (sdk.canOpen({ action: "view", type: "pet.profile" })) {
  sdk.open({ action: "view", type: "pet.profile", id: profileId });
}

The owning module declares which of its pages handles that intent and keeps its path to itself. The registry carries those declarations; the shell resolves the intent to a page, fills the identifier into that page's params, and builds the URL from whatever path the registry reports at that moment.

identifier. What does not cross is the target's path, its internals, or any of its data.

What crosses the boundary is deliberately minimal: an action, an entity type, and an identifier. What does not cross is the target's path, its internal structure, or any of its data. And an identifier is not an authorization — the target loads its own records through its own routes, and its own access check runs there, which is the only place it can be made correctly. If no module handles a type, or the operator holds no action on the module that does, the shell shows "not available" and the asking screen never learns which of the two it was; a capability check lets it decide whether to render the call-to-action at all, without leaking why.

Renames then go through aliases: an old page id or path is retained for two releases, so a bookmark redirects while an intent resolves to the new page from the first deploy — because it never held the path. Bootstrap refuses an alias that shadows a live id.

The trade-off: indirection moves the failure rather than removing it. A caller referencing an entity type nobody handles compiles cleanly and fails at runtime, so bootstrap validation and the capability check are load-bearing, not optional. And the set of actions is closed by design — adding one is a platform change, not a module change.

What This Cost

Not much in code, which is the point of settling the rules before the build. Retention is a branch in the build script, a CI step and a test. The versions are two asserted values, one check, a snapshot per module and a contract test. Generated clients extend a codegen step the platform already had. Intents are a declaration per target page and a resolver in the shell — and what they remove was one line of code carrying the entire coupling.

check that keeps it true. The cost is not the code; it's the discipline, which is what the checks are for.

The real cost is policy. Retention windows, expand-and-contract field lifecycles, alias lifetimes and a closed action set are all commitments somebody has to keep honouring after the interesting work is done. That is why each one names the test that enforces it: a rule that is merely remembered is a rule broken by the second engineer to touch the code.

Where this stands: retention and the two compatibility contracts were exercised in a working prototype. Entity-based routing replaced path-based routing in the design after that prototype demonstrated what path coupling costs. The production implementation is in progress, so the checks described here are what the rulebook commits to — not a claim that each has already run green.

Takeaways

  1. A deploy is an overlap, not an instant. Design for the client that was running before it and will still be running after.
  2. Separate asset availability from contract compatibility. Retaining old files keeps a tab loading; only a declared contract keeps its next request meaningful.
  3. State the integrity guarantee you actually implement. Content addressing gives immutability; browser verification needs supported integrity metadata. They are not the same promise.
  4. A version nobody is forced to bump is decoration. Pin the surface in a snapshot and let a check refuse the change until the number moves.
  5. Let callers name intent, not routes. A module can then change its URLs without any caller knowing its structure — and the target still authorizes every request itself.

Hoomanely builds connected pet-care products: a smart feeding bowl and the EverWiz app that pet parents use to track feeding, health and community. Behind them is a team of operators who keep a console open all day, often mid-conversation with a worried pet parent. A release landing in the middle of that conversation should be something they never notice — not an interruption they absorb on our behalf. Treating the already-open tab as a first-class client is how the cost of shipping stays on our side of the screen.