# A broken extension took the site down

2026-08-10, the afternoon of the day 1.1.0 shipped. convoro.co returned 500 on
every page for as long as it took to find the row and flip it by hand.

Written up because the promise it broke is one of the load-bearing ones: people
install extensions on a forum other people are reading.

---

## What happened

The first port — `disposable` — was installed on convoro.co. Every request
after that returned 500. The log said:

```
ERROR: No binding found for [registration_checks].
```

`registration_checks` is the seam that port needed and the reason it went
first. It landed in `0254f47`, which was pushed **after** 1.1.0 was cut.
Production was on 1.1.0. The seam was not there.

The extension's `boot()` asked for it anyway, `boot()` runs on every request,
and nothing caught the throw.

## Why the version constraint did not save it

It should have. `Installer::assertCoreVersion()` is real, it is enforced, and
the manifest did declare one:

```json
"convoro": "^1.1.0"
```

Production *was* 1.1.0. The constraint was satisfied and the install was
allowed — correctly, by the letter of what it said. The extension had simply
declared a looser constraint than the seam it used, and nothing but care
connects those two facts.

That is the first defect, and it is mine: **the constraint has to name the
version that carries what the extension actually reaches for**, not the version
that happened to be current when the port started.

## Why it took the whole site down

`docs/extensions/README.md` says a broken extension never takes the site down.

That was **only ever true of hook callbacks.** Those are wrapped. `register()`
and `boot()` were not, and they are the two methods every extension has.

Worse than the 500: `boot()` also runs for the command line, so

```
php tools/convoro ext:disable disposable
```

threw the same fatal. The documented way out of the situation was broken by the
situation. The site came back with an `UPDATE` against `cv_extensions`.

🚨 **A recovery path that runs the failing code is not a recovery path.**

## What changed

`ModuleManager::register()` and `boot()` now catch around each module. An
extension that throws is dropped, the reason is recorded for Admin →
Extensions, and it is logged with the phase it died in. The site serves.

A **core** module that throws is still fatal, and that half must not be
softened. A core module failing is a broken build; swallowing it would ship a
site that boots with half a product missing and no error anywhere.

`ExtensionContainmentTest` holds all four of those facts, including the last
one — it reads `ModuleManager` and fails if the core rethrow is ever removed.

The extension side got a guard too:

```php
if (!$this->app->bound('registration_checks')) { ... return; }
```

Containment already keeps the site up, but *silently* contained is a mystery to
whoever installed it. The extensions directory can also be copied between
sites, or Convoro updated downwards, and neither goes through the installer
where the constraint is checked.

## What this costs the next 42 ports

One line in the manifest, thought about rather than copied: **which Convoro
version first shipped every seam this uses.** And where an extension reaches
for a binding it did not itself register, `bound()` first.

## The bit worth remembering

The promise was in the docs, and the docs were the only place it was true. A
test now says it in the one language that cannot drift.
